From 30645f53e5ca7d5936d077af5b5469b01cfc0931 Mon Sep 17 00:00:00 2001 From: Gil Birman Date: Tue, 3 Nov 2026 06:44:38 -0600 Subject: [PATCH] fix(relay): serialize archived channel writes Keep mutation gates, transaction-scoped callers, auto-join fixes, and their concurrency tests in one buildable layer. Lifecycle activation stays off until the complete stack lands. Co-authored-by: Claude Co-authored-by: Codex Signed-off-by: Gil Birman --- crates/buzz-admin/src/main.rs | 30 +- crates/buzz-db/src/lib.rs | 11 +- crates/buzz-db/src/runtime/observability.rs | 2 +- crates/buzz-db/src/runtime/tests.rs | 35 +- .../tests/thread_window_postgres_tests.rs | 14 +- .../src/store/admission_postgres_tests.rs | 33 +- crates/buzz-db/src/store/artifact.rs | 43 +- .../src/store/artifact_postgres_tests.rs | 22 +- crates/buzz-db/src/store/channel.rs | 266 ++- crates/buzz-db/src/store/channel_members.rs | 455 ++++- crates/buzz-db/src/store/deletion.rs | 20 +- crates/buzz-db/src/store/event.rs | 273 ++- crates/buzz-db/src/store/event_deletion.rs | 355 ++++ .../store/event_follow_up_postgres_tests.rs | 183 +- crates/buzz-db/src/store/feed.rs | 12 +- crates/buzz-db/src/store/lifecycle.rs | 146 +- crates/buzz-db/src/store/mod.rs | 2 + crates/buzz-db/src/store/operator_listener.rs | 53 +- .../personal_read/arrival_postgres_tests.rs | 24 +- .../participation_postgres_tests.rs | 7 +- .../src/store/personal_read/postgres_tests.rs | 73 +- .../personal_read/threads_postgres_tests.rs | 15 +- crates/buzz-db/src/store/push.rs | 6 +- crates/buzz-db/src/store/reaction.rs | 118 +- crates/buzz-db/src/store/read_state.rs | 20 +- .../buzz-db/src/store/relay_admin_actions.rs | 1 + crates/buzz-db/src/store/reminder.rs | 32 +- crates/buzz-db/src/store/replaceable.rs | 447 ++++- crates/buzz-db/src/store/thread.rs | 112 +- .../src/store/thread_window/postgres_tests.rs | 13 +- crates/buzz-db/src/store/verified_profiles.rs | 2 + crates/buzz-db/src/store/workflow.rs | 214 ++- crates/buzz-db/src/store/workflow/deletion.rs | 90 +- .../src/store/workflow/deletion_tests.rs | 104 +- crates/buzz-db/tests/observability_source.rs | 2 +- .../src/api/artifact_postgres_tests.rs | 13 +- .../sync_postgres_tests.rs | 1 + crates/buzz-relay/src/api/bridge.rs | 34 +- .../src/api/bridge/read_state_snapshot.rs | 35 +- .../bridge/thread_window/postgres_tests.rs | 19 +- .../postgres_tests/failure_postgres_tests.rs | 7 +- .../src/api/buzz_v1/postgres_tests.rs | 36 +- crates/buzz-relay/src/api/git/policy.rs | 2 +- .../buzz-relay/src/api/git/settings_tests.rs | 15 +- crates/buzz-relay/src/api/git/transport.rs | 2 +- crates/buzz-relay/src/audio/handler.rs | 25 +- crates/buzz-relay/src/handlers/artifact.rs | 9 +- .../src/handlers/command_executor.rs | 192 +- .../handlers/edit_metadata_postgres_tests.rs | 25 +- .../src/handlers/identity_archive.rs | 16 +- crates/buzz-relay/src/handlers/ingest.rs | 151 +- .../src/handlers/lifecycle_postgres_tests.rs | 2 + .../archive_gate_postgres_tests.rs | 1664 +++++++++++++++++ .../src/handlers/moderation_notices.rs | 15 +- .../buzz-relay/src/handlers/side_effects.rs | 756 ++++---- .../handlers/system_channel_postgres_tests.rs | 7 +- crates/buzz-relay/src/main.rs | 10 +- crates/buzz-relay/src/nip_fi_test_hooks.rs | 5 +- crates/buzz-relay/src/workflow_sink.rs | 45 +- crates/buzz-workflow/src/lib.rs | 245 ++- 60 files changed, 5621 insertions(+), 945 deletions(-) create mode 100644 crates/buzz-db/src/store/event_deletion.rs create mode 100644 crates/buzz-relay/src/handlers/lifecycle_postgres_tests/archive_gate_postgres_tests.rs diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index b286037ac1d..be150ce12b0 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -818,8 +818,14 @@ async fn reconcile_channels( .tags(tags) .sign_with_keys(&relay_keys) .map_err(|e| anyhow::anyhow!("sign kind:39000: {e}"))?; - db.replace_addressable_event(tenant.community(), &event, Some(channel.id)) - .await?; + db.replace_addressable_event( + tenant.community(), + &event, + Some(channel.id), + // Relay discovery state: archived channels still describe themselves. + buzz_db::ArchiveGate::Exempt, + ) + .await?; } // kind:39001 — admins @@ -841,8 +847,14 @@ async fn reconcile_channels( .tags(tags) .sign_with_keys(&relay_keys) .map_err(|e| anyhow::anyhow!("sign kind:39001: {e}"))?; - db.replace_addressable_event(tenant.community(), &event, Some(channel.id)) - .await?; + db.replace_addressable_event( + tenant.community(), + &event, + Some(channel.id), + // Relay discovery state: archived channels still describe themselves. + buzz_db::ArchiveGate::Exempt, + ) + .await?; } } @@ -862,8 +874,14 @@ async fn reconcile_channels( .tags(tags) .sign_with_keys(&relay_keys) .map_err(|e| anyhow::anyhow!("sign kind:39002: {e}"))?; - db.replace_addressable_event(tenant.community(), &event, Some(channel.id)) - .await?; + db.replace_addressable_event( + tenant.community(), + &event, + Some(channel.id), + // Relay discovery state: archived channels still describe themselves. + buzz_db::ArchiveGate::Exempt, + ) + .await?; } reconciled += 1; diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 62cbf12d430..0e97cb61759 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -66,11 +66,11 @@ pub(crate) use runtime::{ }; pub use store::{ admin_moderation, allowlist, api_token, archived_identities, artifact, artifact_sync, channel, - channel_members, community, deletion, dm, entity_deletion, event, feed, git_repo, lifecycle, - moderation, nip_fi_binding, operator_listener, partition, personal_read, product_feedback, - push, reaction, read_state, relay_admin_actions, relay_invite, relay_members, relay_operators, - reminder, replaceable, storage_accounting, thread, thread_window, usage, user, user_deletion, - user_deletion_fences, workflow, + channel_members, community, deletion, dm, entity_deletion, event, event_deletion, feed, + git_repo, lifecycle, moderation, nip_fi_binding, operator_listener, partition, personal_read, + product_feedback, push, reaction, read_state, relay_admin_actions, relay_invite, relay_members, + relay_operators, reminder, replaceable, storage_accounting, thread, thread_window, usage, user, + user_deletion, user_deletion_fences, workflow, }; pub use allowlist::AllowlistEntry; @@ -83,6 +83,7 @@ pub use community::{ pub use error::{DbError, Result}; pub use event::{ChannelHeadPrecondition, ChannelHeadWriteStatus}; pub use event::{EventQuery, DEFAULT_MAX_PAGE_LIMIT}; +pub use lifecycle::{ArchiveGate, WriteGates}; pub use nip_fi_binding::{NipFiBindingRecord, NipFiUnbind, RowCapExceeded}; pub use reaction::ReactionEventInsertOutcome; pub use reminder::DueReminder; diff --git a/crates/buzz-db/src/runtime/observability.rs b/crates/buzz-db/src/runtime/observability.rs index 6117eb78357..31c88e12457 100644 --- a/crates/buzz-db/src/runtime/observability.rs +++ b/crates/buzz-db/src/runtime/observability.rs @@ -1567,7 +1567,7 @@ mod tests { .with_inactive_community_fence(test_scope, |_| ()) .await; let _ = writer_db.usage_community_count().await; - let _ = writer_db.reap_expired_ephemeral_channels().await; + let _ = writer_db.reap_expired_ephemeral_channels(1).await; let deletion_store = writer_db.deletion_store(); let _ = deletion_store.reap_expired_serving_write_leases(1).await; let _ = deletion_store.serving_lease_stats().await; diff --git a/crates/buzz-db/src/runtime/tests.rs b/crates/buzz-db/src/runtime/tests.rs index b7c9d5fe2ac..969d94a5e0a 100644 --- a/crates/buzz-db/src/runtime/tests.rs +++ b/crates/buzz-db/src/runtime/tests.rs @@ -591,7 +591,13 @@ async fn migration_schema_database_guard_covers_legacy_writer_and_nip09_deletion let replace_c = c.clone(); let replace_task = tokio::spawn(async move { replace_db - .replace_parameterized_event(community, &replace_c, &replace_d_tag, None) + .replace_parameterized_event( + community, + &replace_c, + &replace_d_tag, + None, + crate::ArchiveGate::Exempt, + ) .await }); tokio::time::sleep(std::time::Duration::from_millis(100)).await; @@ -930,6 +936,7 @@ async fn insert_top_level(pool: &PgPool, community: Uuid, channel: Uuid, ev: &no depth: 0, broadcast: true, }), + crate::ArchiveGate::Exempt, ) .await .expect("insert top-level event"); @@ -962,6 +969,7 @@ async fn insert_thread_reply( depth: 1, broadcast: false, }), + crate::ArchiveGate::Exempt, ) .await .expect("insert reply"); @@ -3283,9 +3291,15 @@ async fn armed_pool_rejects_old_channel_inserts_through_public_api() { // insert_event (single INSERT, autocommit): old channel row rejected. let old = signed_event_at(&author, "old-direct", now_secs - floor - 60); - let err = event::insert_event(&db.pool, cid, &old, Some(channel)) - .await - .expect_err("armed pool must reject below-floor channel inserts"); + let err = event::insert_event( + &db.pool, + cid, + &old, + Some(channel), + crate::ArchiveGate::Exempt, + ) + .await + .expect_err("armed pool must reject below-floor channel inserts"); assert!( err.to_string().contains("below the replica-fence floor"), "unexpected error: {err}" @@ -3311,6 +3325,7 @@ async fn armed_pool_rejects_old_channel_inserts_through_public_api() { depth: 0, broadcast: true, }), + crate::ArchiveGate::Exempt, ) .await .expect_err("armed pool must reject below-floor thread-metadata inserts"); @@ -3321,9 +3336,15 @@ async fn armed_pool_rejects_old_channel_inserts_through_public_api() { // Fresh events pass through both APIs. let fresh = signed_event_at(&author, "fresh-direct", now_secs); - event::insert_event(&db.pool, cid, &fresh, Some(channel)) - .await - .expect("fresh insert passes the armed guard"); + event::insert_event( + &db.pool, + cid, + &fresh, + Some(channel), + crate::ArchiveGate::Exempt, + ) + .await + .expect("fresh insert passes the armed guard"); // Close before the `scratch` guard force-drops the database on return. db.pool.close().await; diff --git a/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs b/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs index b5ef396b746..6db6ddd34e7 100644 --- a/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs +++ b/crates/buzz-db/src/runtime/tests/thread_window_postgres_tests.rs @@ -87,10 +87,16 @@ async fn thread_window_upper_fence_terminal_snapshot_and_fallback() { .sign_with_keys(&keys) .unwrap(); for pool in [&writer, &replica] { - event::insert_event(pool, community, &edit, Some(channel)) - .await - .unwrap(); - event::insert_event(pool, community, &deletion, None) + event::insert_event( + pool, + community, + &edit, + Some(channel), + crate::ArchiveGate::Exempt, + ) + .await + .unwrap(); + event::insert_event(pool, community, &deletion, None, crate::ArchiveGate::Exempt) .await .unwrap(); } diff --git a/crates/buzz-db/src/store/admission_postgres_tests.rs b/crates/buzz-db/src/store/admission_postgres_tests.rs index c5b23f41e6a..11a5cae2090 100644 --- a/crates/buzz-db/src/store/admission_postgres_tests.rs +++ b/crates/buzz-db/src/store/admission_postgres_tests.rs @@ -318,9 +318,16 @@ async fn workflow_writers_require_application_admission() { .expect_err("workflow create must reject"), ); assert_fenced( - super::workflow::create_workflow_run(&db.pool, *community, Uuid::new_v4(), None, None) - .await - .expect_err("workflow run create must reject"), + super::workflow::create_workflow_run( + &db.pool, + *community, + Uuid::new_v4(), + None, + None, + crate::ArchiveGate::Exempt, + ) + .await + .expect_err("workflow run create must reject"), ); assert_fenced( super::workflow::claim_scheduled_workflow_fire( @@ -328,6 +335,7 @@ async fn workflow_writers_require_application_admission() { *community, Uuid::new_v4(), chrono::Utc::now(), + crate::ArchiveGate::Exempt, ) .await .expect_err("schedule claim must reject"), @@ -644,9 +652,14 @@ async fn personal_read_writer_requires_application_admission() { let event = EventBuilder::new(Kind::Custom(9), "read") .sign_with_keys(&Keys::generate()) .expect("sign event"); - db.insert_event(communities[0], &event, Some(channel.id)) - .await - .expect("insert active event"); + db.insert_event( + communities[0], + &event, + Some(channel.id), + crate::ArchiveGate::Exempt, + ) + .await + .expect("insert active event"); let intent = super::personal_read::ReadIntent::MarkThrough { target: super::personal_read::ReadTarget { channel_id: channel.id, @@ -903,7 +916,7 @@ async fn channel_reap_admits_active_tenant_and_skips_quiescing_tenant() { .await .expect("seed channel"); } - let reaped = super::channel::reap_expired_ephemeral_channels(&db.pool) + let reaped = super::channel::reap_expired_ephemeral_channels(&db.pool, 1000) .await .unwrap(); assert_eq!(reaped.len(), 1); @@ -1000,7 +1013,7 @@ async fn channel_reap_returns_committed_rows_after_earlier_tenant_error() { .await .expect("seed expired channel"); } - let reaped = super::channel::reap_expired_ephemeral_channels(&db.pool) + let reaped = super::channel::reap_expired_ephemeral_channels(&db.pool, 1000) .await .expect("tenant failure must not stop the fleet sweep"); assert_eq!(reaped.len(), 1); @@ -1021,7 +1034,7 @@ async fn channel_reap_returns_committed_rows_after_earlier_tenant_error() { .execute(&db.pool) .await .unwrap(); - let retried = super::channel::reap_expired_ephemeral_channels(&db.pool) + let retried = super::channel::reap_expired_ephemeral_channels(&db.pool, 1000) .await .unwrap(); assert_eq!(retried.len(), 1); @@ -1181,7 +1194,7 @@ async fn channel_reap_rechecks_admission_after_candidate_read() { .unwrap(); let pool = db.pool.clone(); let reaped = crate::test_support::fence_while_sweep_waits(community, async move { - super::channel::reap_expired_ephemeral_channels(&pool).await + super::channel::reap_expired_ephemeral_channels(&pool, 1000).await }) .await .unwrap(); diff --git a/crates/buzz-db/src/store/artifact.rs b/crates/buzz-db/src/store/artifact.rs index c72383f67b7..dfbf4fec659 100644 --- a/crates/buzz-db/src/store/artifact.rs +++ b/crates/buzz-db/src/store/artifact.rs @@ -4,12 +4,13 @@ //! before this transaction; here the relay only serializes each identity and //! compares the expected head. use crate::lifecycle::EdgeChange; -use crate::{Db, DbError, Result}; +use crate::{ArchiveGate, Db, DbError, Result}; use buzz_core::artifact::{ArtifactEnvelope, ArtifactOp}; use buzz_core::lifecycle::{task_edge, TaskEdge, TASK_ARTIFACT_TYPE}; use buzz_core::{CommunityId, StoredEvent}; use nostr::{Event, EventBuilder, Keys, Kind, Tag}; use sqlx::Row; +use std::collections::BTreeSet; use uuid::Uuid; /// Acceptance outcome. Only a stale-`prev` conflict carries the current head, @@ -101,6 +102,14 @@ impl Db { /// transaction, under its family locks. With `lifecycle_guard` off, a /// `parent` tag is refused unless the channel already has that projected /// parent, and a channel without a projection row keeps today's behavior. + /// + /// `gate` decides whether an archived channel refuses the revision (see + /// [`ArchiveGate`]). Under [`ArchiveGate::Refuse`] the revision takes the + /// gates of its home and, for a move, of `authorized_source`, after the + /// family locks of a task. An archived home fails with + /// [`DbError::AccessDenied`]; an archived move source answers like a + /// missing head. + #[allow(clippy::too_many_arguments)] pub async fn accept_artifact( &self, community: CommunityId, @@ -109,6 +118,7 @@ impl Db { authorized_source: Option, relay_keys: &Keys, lifecycle_guard: bool, + gate: ArchiveGate, ) -> Result { // A family root that moves before its lock is taken is retried once. for _ in 0..2 { @@ -120,6 +130,7 @@ impl Db { authorized_source, relay_keys, lifecycle_guard, + gate, ) .await? { @@ -129,6 +140,7 @@ impl Db { Ok(ArtifactOutcome::Conflict("task family changed")) } + #[allow(clippy::too_many_arguments)] async fn accept_artifact_once( &self, community: CommunityId, @@ -137,11 +149,20 @@ impl Db { authorized_source: Option, relay_keys: &Keys, lifecycle_guard: bool, + gate: ArchiveGate, ) -> Result> { let mut tx = self.begin_event_write_transaction(community).await?; sqlx::query("SET LOCAL statement_timeout='5s'") .execute(tx.conn()) .await?; + // The channels this revision writes: its home and a move's source. + let mut gated = BTreeSet::new(); + if gate == ArchiveGate::Refuse { + gated.insert(env.home); + if env.op == ArtifactOp::Move { + gated.extend(authorized_source); + } + } let is_task = env.artifact_type == TASK_ARTIFACT_TYPE; // A task channel, or any task revision once the guard is on, takes // the hierarchy path; the read is re-checked under the family lock. @@ -154,15 +175,31 @@ impl Db { Err(message) => return Ok(Some(ArtifactOutcome::Rejected(message))), }; let author = event.pubkey.to_bytes(); - match crate::lifecycle::lock_task_edge(&mut tx, edge.channel, edge.parent, &author) - .await? + match crate::lifecycle::lock_task_edge( + &mut tx, + edge.channel, + edge.parent, + &author, + &gated, + ) + .await? { Some(locks) => Some((edge, locks)), None => return Ok(None), } } else { + crate::lifecycle::lock_shared_gates(&mut tx, &gated).await?; None }; + let archived = crate::lifecycle::archived_among(&mut tx, &gated).await?; + if archived.contains(&env.home) { + return Err(DbError::AccessDenied( + crate::lifecycle::CHANNEL_ARCHIVED.into(), + )); + } + if !archived.is_empty() { + return Ok(Some(ArtifactOutcome::Conflict(HEAD_UNAVAILABLE))); + } // A coordinate lock handles the missing-row create race as well as edits. sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1,0))") .bind(format!("artifact:{}:{}", community.as_uuid(), env.id)) diff --git a/crates/buzz-db/src/store/artifact_postgres_tests.rs b/crates/buzz-db/src/store/artifact_postgres_tests.rs index 73fae7f7e9d..629a69979f6 100644 --- a/crates/buzz-db/src/store/artifact_postgres_tests.rs +++ b/crates/buzz-db/src/store/artifact_postgres_tests.rs @@ -97,7 +97,15 @@ impl Fixture { async fn accept(&self, e: &Event, source: Option) -> ArtifactOutcome { let env = artifact::validate(e).unwrap(); self.db - .accept_artifact(self.community, e, &env, source, &self.relay, false) + .accept_artifact( + self.community, + e, + &env, + source, + &self.relay, + false, + crate::ArchiveGate::Exempt, + ) .await .unwrap() } @@ -470,7 +478,7 @@ async fn root_anchor_and_tenant_boundaries() { .tags([Tag::parse(["h", &f.a.to_string()]).unwrap()]) .sign_with_keys(&f.owner) .unwrap(); - f.db.insert_event(f.community, &anchor, Some(f.a)) + f.db.insert_event(f.community, &anchor, Some(f.a), crate::ArchiveGate::Exempt) .await .unwrap(); let root = || vec![vec!["root".to_string(), anchor.id.to_hex()]]; @@ -563,7 +571,15 @@ async fn quiescing_community_rejects_artifact_at_admission_before_coordinate_loc .unwrap(); let error = tokio::time::timeout( std::time::Duration::from_secs(5), - f.db.accept_artifact(f.community, &create, &env, None, &f.relay, false), + f.db.accept_artifact( + f.community, + &create, + &env, + None, + &f.relay, + false, + crate::ArchiveGate::Exempt, + ), ) .await .expect("admission must reject before waiting on the coordinate lock") diff --git a/crates/buzz-db/src/store/channel.rs b/crates/buzz-db/src/store/channel.rs index 724b542e595..f15370e6cca 100644 --- a/crates/buzz-db/src/store/channel.rs +++ b/crates/buzz-db/src/store/channel.rs @@ -605,7 +605,9 @@ pub async fn get_canvas( Ok(row.try_get("canvas")?) } -/// Sets or clears the canvas content for a channel. +/// Sets or clears the canvas content for a channel. Test-only: it takes no +/// write gate, and production writes a canvas as a kind:40100 event. +#[cfg(test)] pub async fn set_canvas( pool: &PgPool, community_id: CommunityId, @@ -1058,6 +1060,9 @@ pub(crate) struct ChannelSettingsWriter<'tx> { ttl: TtlLock, previous_visibility: String, archived: bool, + /// Whether the channel's gate is held EXCLUSIVE. Only a writer that + /// holds it may change archive state. + archive_gate: bool, listed: bool, } @@ -1133,7 +1138,27 @@ pub(crate) async fn lock_channel_for_settings_write<'tx>( } else { TtlLock::Shared }; - lock_channel_row(tx, channel_id, updates.changes_admission(), ttl).await + lock_channel_row(tx, channel_id, updates.changes_admission(), ttl, false).await +} + +/// Like [`lock_channel_for_settings_write`], for a write that changes the +/// channel's archive state. It first takes the channel's gate EXCLUSIVE +/// (`lifecycle::lock_gates`). A write that [`crate::ArchiveGate::Refuse`] +/// guards holds the gate shared, so it either commits before this write or +/// waits and then reads the new archive state. Only a writer made here may +/// call `set_archived_on`. +pub(crate) async fn lock_channel_for_archive_write<'tx>( + tx: &'tx mut crate::AdmittedTx, + channel_id: Uuid, + updates: &ChannelUpdate, +) -> Result> { + crate::lifecycle::lock_gates(tx, &std::collections::BTreeSet::from([channel_id])).await?; + let ttl = if updates.ttl_seconds.is_some() { + TtlLock::Exclusive + } else { + TtlLock::Shared + }; + lock_channel_row(tx, channel_id, updates.changes_admission(), ttl, true).await } /// Takes the locks that deleting `channel_id` needs, in the same order as @@ -1145,7 +1170,7 @@ async fn lock_channel_for_delete( tx: &mut crate::AdmittedTx, channel_id: Uuid, ) -> Result> { - lock_channel_row(tx, channel_id, true, TtlLock::Shared).await + lock_channel_row(tx, channel_id, true, TtlLock::Shared, false).await } async fn lock_channel_row( @@ -1153,6 +1178,7 @@ async fn lock_channel_row( channel_id: Uuid, admission: bool, ttl: TtlLock, + archive_gate: bool, ) -> Result> { let community_id = tx.community(); if admission { @@ -1184,6 +1210,7 @@ async fn lock_channel_row( ttl, previous_visibility, archived, + archive_gate, listed, }) } @@ -1420,7 +1447,7 @@ pub async fn archive_channel( ) .await?; let mut locked = - lock_channel_for_settings_write(&mut tx, channel_id, &ChannelUpdate::default()).await?; + lock_channel_for_archive_write(&mut tx, channel_id, &ChannelUpdate::default()).await?; if crate::lifecycle::is_task_channel(locked.tx(), channel_id).await? { tx.rollback().await?; return Ok(false); @@ -1446,7 +1473,7 @@ pub async fn unarchive_channel( ) .await?; let mut locked = - lock_channel_for_settings_write(&mut tx, channel_id, &ChannelUpdate::default()).await?; + lock_channel_for_archive_write(&mut tx, channel_id, &ChannelUpdate::default()).await?; refuse_task_channel(&mut locked).await?; set_archived_on(&mut locked, false).await?; tx.commit().await?; @@ -1465,8 +1492,15 @@ async fn refuse_task_channel(w: &mut ChannelSettingsWriter<'_>) -> Result<()> { /// Archives (`archive = true`) or restores the channel on `tx`. Refuses with /// `AccessDenied` when the channel is already in that state. Restoring an -/// ephemeral channel restarts its TTL deadline. +/// ephemeral channel restarts its TTL deadline. Fails closed with +/// `InvalidData` unless the writer holds the channel's gate exclusive +/// ([`lock_channel_for_archive_write`]). async fn set_archived_on(w: &mut ChannelSettingsWriter<'_>, archive: bool) -> Result<()> { + if !w.archive_gate { + return Err(DbError::InvalidData( + "archive state write is not covered by its gate".into(), + )); + } let channel_id = w.channel_id; let tx = &mut *w.tx; let community_id = tx.community(); @@ -1564,8 +1598,14 @@ pub(crate) async fn apply_channel_metadata_event( tx.set_push_enqueue(push_enqueue); // The row lock also orders two copies of the same event: the second - // waits, then sees the first stored. - let mut locked = lock_channel_for_settings_write(&mut tx, channel_id, &edit.columns).await?; + // waits, then sees the first stored. An edit that changes archive state + // takes the gate exclusive first. A settings-only edit takes no gate: it + // is refused below, under the row lock, once the channel is archived. + let mut locked = if edit.archived.is_some() { + lock_channel_for_archive_write(&mut tx, channel_id, &edit.columns).await? + } else { + lock_channel_for_settings_write(&mut tx, channel_id, &edit.columns).await? + }; let previous_visibility = locked.previous_visibility().to_owned(); // The same writes as `Db::insert_event_with_thread_metadata`. @@ -1720,12 +1760,16 @@ pub struct ChannelDeleteWrite { /// decided under the channel's locks (a task that has child tasks) stores /// nothing. An event for a channel that is already gone is still stored, so /// a resend of it is a duplicate. +/// +/// `gate` decides whether an archived channel refuses the delete (see +/// [`crate::ArchiveGate`]); the gate comes before the channel's locks. pub(crate) async fn delete_channel_with_event( pool: &PgPool, push_enqueue: Option, community_id: CommunityId, channel_id: Uuid, event: &nostr::Event, + gate: crate::ArchiveGate, ) -> Result { let mut tx = crate::begin_community_event_write_transaction( pool, @@ -1734,6 +1778,7 @@ pub(crate) async fn delete_channel_with_event( ) .await?; tx.set_push_enqueue(push_enqueue); + crate::lifecycle::lock_write_gates(&mut tx, gate, &[channel_id]).await?; // The locks also order two copies of the same event: the second waits, // then sees the first stored. let write = match lock_channel_for_delete(&mut tx, channel_id).await { @@ -1814,12 +1859,24 @@ async fn soft_delete_on(w: &mut ChannelSettingsWriter<'_>) -> Result<()> { Ok(()) } -/// Archive ephemeral channels whose TTL deadline has passed. +/// Archive ephemeral channels whose TTL deadline has passed: at most `limit` +/// per community per call, in one transaction per community. +/// +/// Each channel costs one gate lock, so one transaction holds at most `limit` +/// gates. It does not loop over batches: every community commits once, and +/// its gates are released at that commit. A backlog drains at `limit` per +/// community per call. A later community's failure keeps the results that +/// earlier communities committed (see `sweep_admitted_communities`). /// /// Returns the `(community_id, host, channel_id)` list that was archived. Idempotent — the /// `archived_at IS NULL` guard prevents double-archiving even if called /// concurrently from multiple relay pods. -pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result> { +pub async fn reap_expired_ephemeral_channels( + pool: &PgPool, + limit: usize, +) -> Result> { + let limit = i64::try_from(limit) + .map_err(|_| DbError::InvalidData(format!("reaper limit {limit} is out of range")))?; let mut connection = crate::observability::acquire_writer( pool, crate::observability::WriterOperation::Maintenance, @@ -1844,16 +1901,23 @@ pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result Result = sqlx::query_scalar( + "SELECT ch.id FROM channels AS ch \ + JOIN communities AS c ON ch.community_id = c.id \ + WHERE ch.community_id = $1 \ + AND ch.ttl_seconds IS NOT NULL AND ch.ttl_deadline < NOW() \ + AND ch.archived_at IS NULL AND ch.deleted_at IS NULL \ + AND c.archived_at IS NULL \ + AND NOT EXISTS (SELECT 1 FROM artifact_parents AS ap \ + WHERE ap.community_id = ch.community_id \ + AND (ap.child_channel_id = ch.id OR ap.parent_channel_id = ch.id)) \ + ORDER BY ch.id LIMIT $2", + ) + .bind(id) + .bind(limit) + .fetch_all(tx.conn()) + .await?; + if candidates.is_empty() { + tx.commit().await?; + return Ok(Vec::new()); + } + crate::lifecycle::lock_gates(&mut tx, &candidates.iter().copied().collect()).await?; let rows = sqlx::query( "UPDATE channels AS ch SET archived_at = NOW(), lifecycle_token = $2 \ FROM communities AS c \ WHERE ch.community_id = $1 AND ch.community_id = c.id \ + AND ch.id = ANY($3) \ AND ch.ttl_seconds IS NOT NULL AND ch.ttl_deadline < NOW() \ AND ch.archived_at IS NULL AND ch.deleted_at IS NULL \ AND c.archived_at IS NULL \ @@ -1876,6 +1962,7 @@ pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result Result { delete_channel_with_event( &self.pool, @@ -2216,14 +2305,19 @@ impl Db { community_id, channel_id, event, + gate, ) .await } - /// Archive ephemeral channels whose TTL deadline has passed. + /// Archive ephemeral channels whose TTL deadline has passed, at most + /// `limit` per community. See [`reap_expired_ephemeral_channels`]. #[datastore_span(name = "reap_expired_ephemeral_channels", system = "postgresql")] - pub async fn reap_expired_ephemeral_channels(&self) -> Result> { - reap_expired_ephemeral_channels(&self.pool).await + pub async fn reap_expired_ephemeral_channels( + &self, + limit: usize, + ) -> Result> { + reap_expired_ephemeral_channels(&self.pool, limit).await } } @@ -2658,7 +2752,7 @@ mod postgres_tests { "unarchive should renew ttl_deadline into the future" ); - let reaped = reap_expired_ephemeral_channels(&pool) + let reaped = reap_expired_ephemeral_channels(&pool, 1000) .await .expect("run reaper"); assert!( @@ -2707,7 +2801,7 @@ mod postgres_tests { .await .expect("expire channel"); - let reaped = reap_expired_ephemeral_channels(&pool) + let reaped = reap_expired_ephemeral_channels(&pool, 1000) .await .expect("run reaper"); assert!( @@ -2720,6 +2814,124 @@ mod postgres_tests { ); } + /// One call archives at most `limit` channels per community, so one + /// transaction holds at most `limit` gates; a backlog drains over later + /// calls. The candidate read skips what the UPDATE skips (channels with + /// a task projection row), so those can never fill the batch and starve + /// an eligible channel. + /// Mutations: dropping `LIMIT` archives all three backlog channels in + /// the first call; dropping the backstop from the candidate read makes + /// the first starvation call archive nothing. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reap_expired_ephemeral_channels_drains_a_backlog_in_batches() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let owner_pk = random_pubkey(); + ensure_user(&pool, community, &owner_pk) + .await + .expect("ensure owner"); + let mut expired = Vec::new(); + for _ in 0..6 { + let channel = create_test_channel( + &pool, + community_id, + &format!("test-reaper-batch-{}", Uuid::new_v4()), + ChannelType::Stream, + ChannelVisibility::Open, + None, + &owner_pk, + Some(60), + ) + .await + .expect("create ephemeral channel"); + expired.push(channel.id); + } + // The last three wait for the starvation step. Sorted, so the two + // that get a projection row come first in the reaper's id order. + let (backlog, rest) = expired.split_at(3); + let mut rest = rest.to_vec(); + rest.sort(); + let (excluded, eligible) = (&rest[..2], rest[2]); + let expire = |ids: Vec| { + sqlx::query( + "UPDATE channels SET ttl_deadline = NOW() - interval '1 second' \ + WHERE community_id = $1 AND id = ANY($2)", + ) + .bind(community_id) + .bind(ids) + .execute(&pool) + }; + let reaped = |limit: usize| { + let pool = pool.clone(); + async move { + reap_expired_ephemeral_channels(&pool, limit) + .await + .expect("run reaper") + .into_iter() + .filter(|row| row.community_id == community) + .map(|row| row.channel_id) + .collect::>() + } + }; + let archived = |id: Uuid| { + let pool = pool.clone(); + async move { + get_channel(&pool, community, id) + .await + .expect("reload channel") + .archived_at + .is_some() + } + }; + + // Backlog: 3 expired channels, 2 per call. + expire(backlog.to_vec()).await.expect("expire backlog"); + let first = reaped(2).await; + assert_eq!(first.len(), 2, "the first call archives exactly limit"); + assert!(first.iter().all(|id| backlog.contains(id))); + for id in backlog { + assert_eq!( + archived(*id).await, + first.contains(id), + "returned = archived" + ); + } + let second = reaped(2).await; + assert_eq!(second.len(), 1, "the second call archives the rest"); + assert!(second.is_disjoint(&first)); + assert!( + reaped(2).await.is_empty(), + "the third call has nothing left" + ); + + // Starvation: two expired channels the UPDATE excludes, and one + // eligible channel, with room for only two candidates. + for child in excluded { + sqlx::query( + "INSERT INTO artifact_parents \ + (community_id, child_channel_id, parent_channel_id, artifact_id) \ + VALUES ($1, $2, NULL, $3)", + ) + .bind(community_id) + .bind(child) + .bind(Uuid::new_v4()) + .execute(&pool) + .await + .expect("insert projection row"); + } + expire(rest.to_vec()).await.expect("expire the rest"); + assert_eq!( + reaped(2).await, + std::collections::BTreeSet::from([eligible]), + "excluded channels never fill the batch" + ); + for id in excluded { + assert!(!archived(*id).await, "a task channel is never reaped"); + } + } + fn create_event(creator: &Keys, channel_id: Uuid) -> nostr::Event { nostr::EventBuilder::new(nostr::Kind::Custom(9007), "") .tags([ diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index 2c139228a8e..0a962420053 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -9,7 +9,7 @@ use uuid::Uuid; use crate::channel::{row_to_channel_record, ChannelRecord}; use crate::error::{DbError, Result}; -use crate::{AdmittedTx, Db}; +use crate::{AdmittedTx, ArchiveGate, Db}; use buzz_core::CommunityId; use buzz_datastore_tracing::datastore_span; @@ -96,9 +96,10 @@ pub(crate) async fn verify_channel_roster_fence_catalog<'e>( Ok(()) } -/// Take the per-channel membership lock. MUST be the first statement in the -/// transaction that then reads roles/owner counts and writes membership, so the -/// whole check-then-write sequence is atomic against a concurrent one. +/// Take the per-channel membership lock. MUST be the first lock in the +/// transaction that then reads roles/owner counts and writes membership (only +/// the channel write gates come before it), so the whole check-then-write +/// sequence is atomic against a concurrent one. pub(crate) async fn acquire_channel_membership_lock( tx: &mut PgConnection, community_id: CommunityId, @@ -654,10 +655,10 @@ pub async fn add_member( /// neither a membership write nor a setting change can slip between the /// decision and the write. /// -/// An archived channel admits nobody new: a target who is not an active -/// member is refused with `AccessDenied` while `archived_at` is set. The -/// archive state is read under the channel row lock, which every archive -/// write needs too (see [`lock_channel_for_member_put`]). +/// An archived channel refuses the write with `AccessDenied`: the channel's +/// write gate comes first ([`ArchiveGate::Refuse`]). A target who is not an +/// active member is refused again under the channel row lock, which every +/// archive write needs too (see [`lock_channel_for_member_put`]). /// /// There is no creator exception: channel creation installs the creator as /// owner in the same transaction, so a creator without an active row was @@ -726,15 +727,81 @@ pub async fn put_members( ) .await?; - // First statements: serialize the whole decision / owner-count / upsert - // sequence against concurrent membership writes and archives of this - // channel. + // First statements: the channel's write gate, then the locks that + // serialize the whole decision / owner-count / upsert sequence against + // concurrent membership writes and archives of this channel. + crate::lifecycle::lock_write_gates(&mut tx, ArchiveGate::Refuse, &[channel_id]).await?; lock_channel_for_member_put(&mut tx, channel_id).await?; let results = put_members_on(&mut tx, channel_id, targets, invited_by).await?; tx.commit().await?; Ok(results) } +/// A membership command event from [`begin_member_event_write`]. +enum MemberEventTx { + /// Stored, with its mentions, in the open transaction. + Stored(AdmittedTx, buzz_core::StoredEvent), + /// Already stored. The transaction is rolled back. + Duplicate(buzz_core::StoredEvent), +} + +/// The locks a membership command event takes after its write gate. +#[derive(Clone, Copy)] +enum MemberEventLocks { + /// The event may admit members: the locks of [`put_members`] + /// ([`lock_channel_for_member_put`]). + Put, + /// The event removes a member: the membership lock only, as in + /// [`remove_member`]. + Remove, +} + +/// Begin the transaction of a membership command `event` in `channel_id` and +/// store the event with its mentions. This is the one place that orders the +/// `*_with_event` locks: the channel's write gate (under `gate`), then +/// `locks`. A second copy of the event waits for the first and then finds it +/// stored. +#[allow(clippy::too_many_arguments)] +async fn begin_member_event_write( + pool: &PgPool, + push_enqueue: Option, + community_id: CommunityId, + channel_id: Uuid, + event: &nostr::Event, + thread_meta: Option>, + gate: ArchiveGate, + locks: MemberEventLocks, +) -> Result { + let mut tx = crate::begin_community_event_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + tx.set_push_enqueue(push_enqueue); + crate::lifecycle::lock_write_gates(&mut tx, gate, &[channel_id]).await?; + match locks { + MemberEventLocks::Put => lock_channel_for_member_put(&mut tx, channel_id).await?, + MemberEventLocks::Remove => { + acquire_channel_membership_lock(tx.conn(), community_id, channel_id).await? + } + } + + let (stored, inserted) = crate::event::insert_event_with_thread_metadata_tx( + &mut tx, + event, + Some(channel_id), + thread_meta, + ) + .await?; + if !inserted { + tx.rollback().await?; + return Ok(MemberEventTx::Duplicate(stored)); + } + crate::insert_mentions_in_transaction(&mut tx, event, Some(channel_id)).await?; + Ok(MemberEventTx::Stored(tx, stored)) +} + /// Self-join `pubkey` to `channel_id` as [`put_member`] with no actor and no /// requested role (so the channel's `join_role` applies), but only if /// `pubkey` has never had a membership row in the channel, active or removed. @@ -757,6 +824,7 @@ pub async fn put_first_member( crate::observability::WriterOperation::EventWrite, ) .await?; + crate::lifecycle::lock_write_gates(&mut tx, ArchiveGate::Refuse, &[channel_id]).await?; lock_channel_for_member_put(&mut tx, channel_id).await?; let seen: bool = sqlx::query_scalar( "SELECT EXISTS (SELECT 1 FROM channel_members \ @@ -812,8 +880,8 @@ pub enum MemberPutEventWrite { /// transaction. If any write fails, nothing is stored, so the client can /// resend the same event and have it applied. /// -/// The locks of [`put_members`] come first, so a second copy of the event -/// waits for the first and then finds it stored. +/// Locks and duplicates as in [`begin_member_event_write`]. +#[allow(clippy::too_many_arguments)] pub(crate) async fn put_members_with_event( pool: &PgPool, push_enqueue: Option, @@ -822,30 +890,24 @@ pub(crate) async fn put_members_with_event( event: &nostr::Event, targets: &[MemberPut<'_>], thread_meta: Option>, + gate: ArchiveGate, ) -> Result { check_distinct_targets(targets)?; - let mut tx = crate::begin_community_event_write_transaction( + let (mut tx, stored) = match begin_member_event_write( pool, + push_enqueue, community_id, - crate::observability::WriterOperation::EventWrite, - ) - .await?; - tx.set_push_enqueue(push_enqueue); - lock_channel_for_member_put(&mut tx, channel_id).await?; - - // The same writes as `Db::insert_event_with_thread_metadata`. - let (stored, inserted) = crate::event::insert_event_with_thread_metadata_tx( - &mut tx, + channel_id, event, - Some(channel_id), thread_meta, + gate, + MemberEventLocks::Put, ) - .await?; - if !inserted { - tx.rollback().await?; - return Ok(MemberPutEventWrite::Duplicate(stored)); - } - crate::insert_mentions_in_transaction(&mut tx, event, Some(channel_id)).await?; + .await? + { + MemberEventTx::Stored(tx, stored) => (tx, stored), + MemberEventTx::Duplicate(stored) => return Ok(MemberPutEventWrite::Duplicate(stored)), + }; let actor = event.pubkey.to_bytes(); let results = put_members_on(&mut tx, channel_id, targets, Some(&actor)).await?; @@ -859,6 +921,146 @@ pub(crate) async fn put_members_with_event( Ok(MemberPutEventWrite::Applied { stored, results }) } +/// The refusal of a kind:9021 join into a channel that is not open, as +/// [`DbError::AccessDenied`]. +pub const CHANNEL_PRIVATE: &str = "channel is private"; + +/// The result of [`Db::join_with_event`]. +pub enum JoinEventWrite { + /// The event was stored and its author joined the channel. + Joined { + /// The stored kind:9021 event. + stored: buzz_core::StoredEvent, + /// The author's membership row after the join. + member: MemberRecord, + }, + /// The event was stored; its author was already an active member, so + /// nothing else changed. + AlreadyMember(buzz_core::StoredEvent), + /// The event was already stored. Nothing changed. + Duplicate(buzz_core::StoredEvent), +} + +/// Store a kind:9021 `event` and add its author to the open channel +/// `channel_id` in one transaction. The new member gets the channel's +/// `join_role`, as from [`put_member`] with no actor. +/// +/// Locks and duplicates as in [`begin_member_event_write`]. A channel that +/// is not open fails with [`DbError::AccessDenied`] ([`CHANNEL_PRIVATE`]); +/// any refusal stores nothing. +pub(crate) async fn join_with_event( + pool: &PgPool, + push_enqueue: Option, + community_id: CommunityId, + channel_id: Uuid, + event: &nostr::Event, + thread_meta: Option>, + gate: ArchiveGate, +) -> Result { + let (mut tx, stored) = match begin_member_event_write( + pool, + push_enqueue, + community_id, + channel_id, + event, + thread_meta, + gate, + MemberEventLocks::Put, + ) + .await? + { + MemberEventTx::Stored(tx, stored) => (tx, stored), + MemberEventTx::Duplicate(stored) => return Ok(JoinEventWrite::Duplicate(stored)), + }; + + if get_channel_tx(tx.conn(), community_id, channel_id) + .await? + .visibility + != "open" + { + return Err(DbError::AccessDenied(CHANNEL_PRIVATE.into())); + } + let actor = event.pubkey.to_bytes(); + if get_active_role_tx(tx.conn(), community_id, channel_id, &actor) + .await? + .is_some() + { + tx.commit().await?; + return Ok(JoinEventWrite::AlreadyMember(stored)); + } + let target = MemberPut { + pubkey: &actor, + requested: None, + }; + let put = put_members_on(&mut tx, channel_id, &[target], None) + .await? + .pop() + .ok_or_else(|| DbError::InvalidData("membership put returned no result".into()))??; + tx.commit().await?; + Ok(JoinEventWrite::Joined { + stored, + member: put.member, + }) +} + +/// The result of [`Db::remove_member_with_event`]. +pub enum MemberRemoveEventWrite { + /// The event was stored and the member was removed. + Removed(buzz_core::StoredEvent), + /// The event was stored, but the target was not an active member, so + /// nothing else changed. + NotMember(buzz_core::StoredEvent), + /// The event was already stored. Nothing changed. + Duplicate(buzz_core::StoredEvent), +} + +/// Store a kind:9001 `event` and remove `pubkey` from `channel_id` with the +/// event's author as the actor, in one transaction, under the rules of +/// [`remove_member`]. +/// +/// Locks and duplicates as in [`begin_member_event_write`]. A refusal +/// decided under the lock ([`DbError::AccessDenied`]) stores nothing. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn remove_member_with_event( + pool: &PgPool, + push_enqueue: Option, + community_id: CommunityId, + channel_id: Uuid, + event: &nostr::Event, + pubkey: &[u8], + thread_meta: Option>, + gate: ArchiveGate, +) -> Result { + let actor = event.pubkey.to_bytes(); + // Immutable, and read before the lock for the reason in `remove_member`. + let actor_is_agent_owner = pubkey != actor.as_slice() + && crate::user::is_agent_owner(pool, community_id, pubkey, &actor).await?; + let (mut tx, stored) = match begin_member_event_write( + pool, + push_enqueue, + community_id, + channel_id, + event, + thread_meta, + gate, + MemberEventLocks::Remove, + ) + .await? + { + MemberEventTx::Stored(tx, stored) => (tx, stored), + MemberEventTx::Duplicate(stored) => return Ok(MemberRemoveEventWrite::Duplicate(stored)), + }; + + let outcome = + match remove_member_on(&mut tx, channel_id, pubkey, &actor, actor_is_agent_owner).await { + Ok(()) => MemberRemoveEventWrite::Removed(stored), + Err(DbError::MemberNotFound(_)) => MemberRemoveEventWrite::NotMember(stored), + Err(error) => return Err(error), + }; + tx.commit().await?; + Ok(outcome) +} + /// Take the locks of a [`put_members`] write on `tx`, in the channel lock /// order (see `channel::lock_channel_for_settings_write`): the membership /// lock, the shared TTL lock, then the channel row `FOR NO KEY UPDATE`. @@ -1116,7 +1318,29 @@ pub async fn remove_member( // the UPDATE against concurrent membership writes on this channel (same key // as `add_member`). acquire_channel_membership_lock(tx.conn(), community_id, channel_id).await?; + remove_member_on( + &mut tx, + channel_id, + pubkey, + actor_pubkey, + actor_is_agent_owner, + ) + .await?; + tx.commit().await?; + Ok(()) +} +/// The checks and write of [`remove_member`], on a transaction that holds +/// `channel_id`'s membership lock. The caller owns the commit. +async fn remove_member_on( + tx: &mut AdmittedTx, + channel_id: Uuid, + pubkey: &[u8], + actor_pubkey: &[u8], + actor_is_agent_owner: bool, +) -> Result<()> { + let community_id = tx.community(); + let is_self_remove = pubkey == actor_pubkey; if !is_self_remove { let actor_role_str = get_active_role_tx(tx.conn(), community_id, channel_id, actor_pubkey) .await? @@ -1169,8 +1393,6 @@ pub async fn remove_member( if result.rows_affected() == 0 { return Err(DbError::MemberNotFound(channel_id)); } - - tx.commit().await?; Ok(()) } @@ -2187,7 +2409,10 @@ impl Db { lock_member_snapshot(&self.pool, community_id, channel_id, relay_pubkey).await } - /// Adds a member to a channel. + /// Adds a member to a channel. An archived channel refuses the write + /// (see [`put_member`]). + /// + /// Test fixture, no production caller: use [`Db::put_members_with_event`]. #[datastore_span(name = "add_member", system = "postgresql")] pub async fn add_member( &self, @@ -2209,7 +2434,11 @@ impl Db { } /// Adds a member or changes a role, deciding the role from the request - /// and current channel state (see [`put_member`]). + /// and current channel state (see [`put_member`]). An archived channel + /// refuses the write. + /// + /// The relay's default-channel auto-join calls this; command events use + /// [`Db::put_members_with_event`]. #[datastore_span(name = "put_member", system = "postgresql")] pub async fn put_member( &self, @@ -2253,6 +2482,7 @@ impl Db { event: &nostr::Event, targets: &[MemberPut<'_>], thread_meta: Option>, + gate: ArchiveGate, ) -> Result { put_members_with_event( &self.pool, @@ -2262,6 +2492,56 @@ impl Db { event, targets, thread_meta, + gate, + ) + .await + } + + /// Stores a kind:9021 `event` and adds its author to the open channel, + /// in one transaction (see [`join_with_event`]). + #[datastore_span(name = "join_with_event", system = "postgresql")] + pub async fn join_with_event( + &self, + community_id: CommunityId, + channel_id: Uuid, + event: &nostr::Event, + thread_meta: Option>, + gate: ArchiveGate, + ) -> Result { + join_with_event( + &self.pool, + self.push_enqueue.clone(), + community_id, + channel_id, + event, + thread_meta, + gate, + ) + .await + } + + /// Stores a kind:9001 `event` and removes `pubkey`, with the event's + /// author as the actor, in one transaction (see + /// [`remove_member_with_event`]). + #[datastore_span(name = "remove_member_with_event", system = "postgresql")] + pub async fn remove_member_with_event( + &self, + community_id: CommunityId, + channel_id: Uuid, + event: &nostr::Event, + pubkey: &[u8], + thread_meta: Option>, + gate: ArchiveGate, + ) -> Result { + remove_member_with_event( + &self.pool, + self.push_enqueue.clone(), + community_id, + channel_id, + event, + pubkey, + thread_meta, + gate, ) .await } @@ -3801,10 +4081,18 @@ mod postgres_tests { // A non-member may not add anyone to a private channel. let refused = event(&outsider); - let write = - put_members_with_event(&pool, None, community, channel.id, &refused, &targets, None) - .await - .expect("refused write"); + let write = put_members_with_event( + &pool, + None, + community, + channel.id, + &refused, + &targets, + None, + crate::ArchiveGate::Exempt, + ) + .await + .expect("refused write"); assert!( matches!(&write, MemberPutEventWrite::Refused(errors) if matches!(errors.as_slice(), [DbError::AccessDenied(_)])), @@ -3819,10 +4107,18 @@ mod postgres_tests { ); let added = event(&owner); - let write = - put_members_with_event(&pool, None, community, channel.id, &added, &targets, None) - .await - .expect("applied write"); + let write = put_members_with_event( + &pool, + None, + community, + channel.id, + &added, + &targets, + None, + crate::ArchiveGate::Exempt, + ) + .await + .expect("applied write"); let MemberPutEventWrite::Applied { results, .. } = write else { panic!("expected the event to apply"); }; @@ -3834,10 +4130,18 @@ mod postgres_tests { remove_member(&pool, community, channel.id, &person, &owner_pk) .await .expect("remove person"); - let write = - put_members_with_event(&pool, None, community, channel.id, &added, &targets, None) - .await - .expect("duplicate write"); + let write = put_members_with_event( + &pool, + None, + community, + channel.id, + &added, + &targets, + None, + crate::ArchiveGate::Exempt, + ) + .await + .expect("duplicate write"); assert!(matches!(write, MemberPutEventWrite::Duplicate(_))); assert_eq!( get_member_role(&pool, community, channel.id, &person) @@ -4180,8 +4484,17 @@ mod postgres_tests { pubkey: &invitee, requested: None, }]; - put_members_with_event(&add_pool, None, community, channel_id, &add, &targets, None) - .await + put_members_with_event( + &add_pool, + None, + community, + channel_id, + &add, + &targets, + None, + crate::ArchiveGate::Refuse, + ) + .await } }); crate::test_support::wait_for_lock_wait(&pool, "put-members-event-archive-race").await; @@ -4945,7 +5258,12 @@ mod postgres_tests { let db = Db::from_pool(pool.clone()); for (name, community, channel, event, trigger) in measured { let app = db - .replace_addressable_event(community, &event, Some(channel)) + .replace_addressable_event( + community, + &event, + Some(channel), + crate::ArchiveGate::Exempt, + ) .await .map(|(_, inserted)| inserted) .map_err(|error| match error { @@ -4980,14 +5298,27 @@ mod postgres_tests { let db = Db::from_pool(pool.clone()); for result in [ - db.insert_event(community, &stale, Some(channel.id)).await, - db.insert_event_with_thread_metadata(community, &stale, Some(channel.id), None) - .await, + db.insert_event( + community, + &stale, + Some(channel.id), + crate::ArchiveGate::Exempt, + ) + .await, + db.insert_event_with_thread_metadata( + community, + &stale, + Some(channel.id), + None, + crate::ArchiveGate::Exempt, + ) + .await, db.replace_parameterized_event( community, &stale, &channel.id.to_string(), Some(channel.id), + crate::ArchiveGate::Exempt, ) .await, ] { @@ -5041,7 +5372,12 @@ mod postgres_tests { let old_id = old.id.as_bytes().to_vec(); let writer = tokio::spawn(async move { Db::from_pool(writer_pool) - .replace_addressable_event(community, &old, Some(channel.id)) + .replace_addressable_event( + community, + &old, + Some(channel.id), + crate::ArchiveGate::Exempt, + ) .await }); // A waiting advisory lock proves the old publication has reached the @@ -5674,10 +6010,15 @@ mod postgres_tests { let base = Timestamp::now().as_secs(); let fresh = roster("admin", base); assert!( - db.replace_addressable_event(community, &fresh, Some(channel)) - .await - .expect("publish canonical role") - .1 + db.replace_addressable_event( + community, + &fresh, + Some(channel), + crate::ArchiveGate::Exempt + ) + .await + .expect("publish canonical role") + .1 ); let stale = roster("member", base + 1); // Reproduce the old binary on one connection: replacement key -> diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index 37d87ae9b3c..c4c280c7f5b 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -8842,7 +8842,15 @@ DROP FUNCTION community_write_fence_excluded_table(NAME); let event = task(&owner, channel, parent); let env = buzz_core::artifact::validate(&event).expect("valid task record"); let outcome = db - .accept_artifact(community, &event, &env, None, &relay, true) + .accept_artifact( + community, + &event, + &env, + None, + &relay, + true, + crate::ArchiveGate::Exempt, + ) .await .expect("accept task record"); assert!(matches!(outcome, ArtifactOutcome::Accepted(_))); @@ -8862,7 +8870,15 @@ DROP FUNCTION community_write_fence_excluded_table(NAME); let late = task(&owner, channels[2], Some(channels[0])); let env = buzz_core::artifact::validate(&late).expect("valid task record"); let error = db - .accept_artifact(community, &late, &env, None, &relay, true) + .accept_artifact( + community, + &late, + &env, + None, + &relay, + true, + crate::ArchiveGate::Exempt, + ) .await .expect_err("a quiescing community must refuse task edges"); assert!( diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index 55e21b5ec89..f89d01b68f1 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -17,7 +17,7 @@ use buzz_core::{CommunityId, StoredEvent}; use buzz_datastore_tracing::datastore_span; use crate::error::{DbError, Result}; -use crate::{AdmittedTx, Db}; +use crate::{AdmittedTx, ArchiveGate, Db}; // Compatibility exports preserve the pre-extraction public event-store paths. pub use crate::reminder::{ @@ -425,12 +425,16 @@ pub async fn huddle_started_link_exists_in_transaction( /// This raw-pool helper does not start application push production. Serving /// callers should use [`Db::insert_event`] with a configured [`Db`]. /// +/// `gate` decides whether an archived `channel_id` refuses the write (see +/// [`ArchiveGate`]). +/// /// Returns `(StoredEvent, was_inserted)` — `was_inserted` is `false` on duplicate. pub async fn insert_event( pool: &PgPool, community_id: CommunityId, event: &Event, channel_id: Option, + gate: ArchiveGate, ) -> Result<(StoredEvent, bool)> { let mut tx = crate::begin_community_event_write_transaction( pool, @@ -438,6 +442,7 @@ pub async fn insert_event( crate::observability::WriterOperation::EventWrite, ) .await?; + crate::lifecycle::lock_write_gates(&mut tx, gate, channel_id.as_slice()).await?; let result = insert_event_in_transaction(&mut tx, event, channel_id).await?; tx.commit().await?; Ok(result) @@ -1351,9 +1356,25 @@ pub async fn soft_delete_by_coordinate( crate::observability::WriterOperation::EventWrite, ) .await?; + let deleted = + soft_delete_by_coordinate_in_tx(&mut tx, kind, pubkey, d_tag, deletion_created_at).await?; + tx.commit().await?; + Ok(deleted) +} + +/// Transaction-scoped body of [`soft_delete_by_coordinate`]. The caller +/// owns the commit. +pub(crate) async fn soft_delete_by_coordinate_in_tx( + tx: &mut AdmittedTx, + kind: i32, + pubkey: &[u8], + d_tag: &str, + deletion_created_at: DateTime, +) -> Result { + let community_id = tx.community(); let purged = if may_be_retention_free(kind) { purge_retention_free_events( - &mut tx, + tx, RetentionFreeTarget::Coordinate { kind, pubkey, @@ -1386,8 +1407,6 @@ pub async fn soft_delete_by_coordinate( .execute(tx.conn()) .await?; - tx.commit().await?; - Ok(purged > 0 || result.rows_affected() > 0) } @@ -1856,7 +1875,7 @@ pub(crate) async fn get_events_by_ids_on( } /// Parameters for [`insert_event_with_thread_metadata`]. -#[derive(Debug)] +#[derive(Clone, Copy, Debug)] pub struct ThreadMetadataParams<'a> { /// The Nostr event ID of this message. pub event_id: &'a [u8], @@ -2108,6 +2127,9 @@ async fn acquire_canvas_coordinate_lock(tx: &mut AdmittedTx, channel_id: Uuid) - /// writes remain unconditional — they never conflict — but must not race the /// head read inside a concurrent tagged transaction. /// +/// `gate` decides whether an archived `channel_id` refuses the write (see +/// [`ArchiveGate`]). +/// /// Returns `(StoredEvent, was_inserted)`. pub async fn insert_event_with_thread_metadata( pool: &PgPool, @@ -2115,6 +2137,7 @@ pub async fn insert_event_with_thread_metadata( event: &Event, channel_id: Option, thread_meta: Option>, + gate: ArchiveGate, ) -> Result<(StoredEvent, bool)> { let mut tx = crate::begin_community_event_write_transaction( pool, @@ -2122,6 +2145,7 @@ pub async fn insert_event_with_thread_metadata( crate::observability::WriterOperation::EventWrite, ) .await?; + crate::lifecycle::lock_write_gates(&mut tx, gate, channel_id.as_slice()).await?; acquire_canvas_event_write_lock_if_needed(&mut tx, event, channel_id).await?; let result = insert_event_with_thread_metadata_tx(&mut tx, event, channel_id, thread_meta).await?; @@ -2190,12 +2214,16 @@ fn candidate_supersedes_head( /// strictly ahead of the head; if not, returns `SupersedeFailed`. Re-submitting /// the byte-identical live head short-circuits to `Duplicate` without evaluating /// the precondition (safe transport-retry semantics). +/// +/// `gate` decides whether an archived channel refuses the write (see +/// [`ArchiveGate`]); the gate comes before the coordinate lock. pub async fn insert_canvas_head_checked( pool: &PgPool, community_id: CommunityId, event: &Event, channel_id: Uuid, precondition: ChannelHeadPrecondition<'_>, + gate: ArchiveGate, ) -> Result<(StoredEvent, ChannelHeadWriteStatus)> { let kind_i32 = buzz_core::kind::event_kind_i32(event); if kind_i32 != KIND_CANVAS as i32 { @@ -2212,6 +2240,7 @@ pub async fn insert_canvas_head_checked( crate::observability::WriterOperation::EventWrite, ) .await?; + crate::lifecycle::lock_write_gates(&mut tx, gate, &[channel_id]).await?; acquire_canvas_coordinate_lock(&mut tx, channel_id).await?; let head: Option<(Vec, DateTime)> = sqlx::query_as( @@ -2285,12 +2314,16 @@ pub async fn insert_canvas_head_checked( impl Db { /// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate. + /// + /// `gate` decides whether an archived `channel_id` refuses the write (see + /// [`ArchiveGate`]). #[datastore_span(name = "insert_event", system = "postgresql")] pub async fn insert_event( &self, community_id: CommunityId, event: &nostr::Event, channel_id: Option, + gate: ArchiveGate, ) -> Result<(StoredEvent, bool)> { let mut tx = crate::begin_community_event_write_transaction( &self.pool, @@ -2299,6 +2332,7 @@ impl Db { ) .await?; tx.set_push_enqueue(self.push_enqueue.clone()); + crate::lifecycle::lock_write_gates(&mut tx, gate, channel_id.as_slice()).await?; let result = crate::event::insert_event_in_transaction(&mut tx, event, channel_id).await?; if result.1 { crate::insert_mentions_in_transaction(&mut tx, event, channel_id).await?; @@ -2681,6 +2715,8 @@ impl Db { /// when it is not newer than the deletion request. /// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds; /// `deletion_created_at_secs` is the deletion event's `created_at`. + /// + /// Ungated test fixture, no production caller: use [`Db::insert_coordinate_deletion`]. #[datastore_span(name = "soft_delete_by_coordinate", system = "postgresql")] pub async fn soft_delete_by_coordinate( &self, @@ -2702,6 +2738,8 @@ impl Db { } /// Atomically soft-delete an event and decrement thread reply counters. + /// + /// Ungated test fixture, no production caller: use [`Db::insert_event_deletion`]. #[datastore_span(name = "soft_delete_event_and_update_thread", system = "postgresql")] pub async fn soft_delete_event_and_update_thread( &self, @@ -2816,6 +2854,9 @@ impl Db { } /// Atomically insert an event AND its thread metadata in a single transaction. + /// + /// `gate` decides whether an archived `channel_id` refuses the write (see + /// [`ArchiveGate`]). #[datastore_span(name = "insert_event_with_thread_metadata", system = "postgresql")] pub async fn insert_event_with_thread_metadata( &self, @@ -2823,6 +2864,7 @@ impl Db { event: &nostr::Event, channel_id: Option, thread_meta: Option>, + gate: ArchiveGate, ) -> Result<(StoredEvent, bool)> { let mut tx = crate::begin_community_event_write_transaction( &self.pool, @@ -2831,6 +2873,7 @@ impl Db { ) .await?; tx.set_push_enqueue(self.push_enqueue.clone()); + crate::lifecycle::lock_write_gates(&mut tx, gate, channel_id.as_slice()).await?; crate::event::acquire_canvas_event_write_lock_if_needed(&mut tx, event, channel_id).await?; let result = crate::event::insert_event_with_thread_metadata_tx( &mut tx, @@ -2888,8 +2931,17 @@ impl Db { event: &nostr::Event, channel_id: Uuid, precondition: ChannelHeadPrecondition<'_>, + gate: ArchiveGate, ) -> Result<(StoredEvent, ChannelHeadWriteStatus)> { - insert_canvas_head_checked(&self.pool, community_id, event, channel_id, precondition).await + insert_canvas_head_checked( + &self.pool, + community_id, + event, + channel_id, + precondition, + gate, + ) + .await } } @@ -3092,7 +3144,9 @@ mod postgres_tests { .unwrap(); let pool = pool.clone(); async move { - insert_event(&pool, community, &event, None).await.unwrap(); + insert_event(&pool, community, &event, None, crate::ArchiveGate::Exempt) + .await + .unwrap(); event.id.as_bytes().to_vec() } }; @@ -3199,7 +3253,9 @@ mod postgres_tests { .custom_created_at(nostr::Timestamp::from(at)) .sign_with_keys(&author) .unwrap(); - insert_event(&pool, community, &event, None).await.unwrap(); + insert_event(&pool, community, &event, None, crate::ArchiveGate::Exempt) + .await + .unwrap(); ids.push(event.id.as_bytes().to_vec()); } let mut q = EventQuery::for_community(community); @@ -3406,9 +3462,15 @@ mod postgres_tests { .custom_created_at(nostr::Timestamp::from(created)) .sign_with_keys(&author) .unwrap(); - insert_event(&pool, community, &event, Some(channel)) - .await - .unwrap(); + insert_event( + &pool, + community, + &event, + Some(channel), + crate::ArchiveGate::Exempt, + ) + .await + .unwrap(); crate::insert_mentions(&pool, community, &event, Some(channel)) .await .unwrap(); @@ -3423,9 +3485,15 @@ mod postgres_tests { .custom_created_at(nostr::Timestamp::from(now - 150)) .sign_with_keys(&author) .unwrap(); - insert_event(&pool, community, &unmentioned, Some(channel)) - .await - .unwrap(); + insert_event( + &pool, + community, + &unmentioned, + Some(channel), + crate::ArchiveGate::Exempt, + ) + .await + .unwrap(); let read = |q: EventQuery| { let pool = pool.clone(); @@ -3521,10 +3589,16 @@ mod postgres_tests { let permanent = make_test_channel(&pool, community_uuid, None).await; let permanent_event = make_text_event("permanent channel event"); assert!( - insert_event(&pool, community, &permanent_event, Some(permanent)) - .await - .expect("insert permanent event") - .1 + insert_event( + &pool, + community, + &permanent_event, + Some(permanent), + crate::ArchiveGate::Exempt + ) + .await + .expect("insert permanent event") + .1 ); let permanent_deadline: Option> = sqlx::query_scalar( "SELECT ttl_deadline FROM channels WHERE community_id = $1 AND id = $2", @@ -3548,10 +3622,16 @@ mod postgres_tests { tokio::time::sleep(std::time::Duration::from_millis(20)).await; let ephemeral_event = make_text_event("ephemeral channel event"); assert!( - insert_event(&pool, community, &ephemeral_event, Some(ephemeral)) - .await - .expect("insert ephemeral event") - .1 + insert_event( + &pool, + community, + &ephemeral_event, + Some(ephemeral), + crate::ArchiveGate::Exempt + ) + .await + .expect("insert ephemeral event") + .1 ); let bumped_deadline: DateTime = sqlx::query_scalar( "SELECT ttl_deadline FROM channels WHERE community_id = $1 AND id = $2", @@ -3563,10 +3643,16 @@ mod postgres_tests { .expect("read bumped ephemeral deadline"); assert!(bumped_deadline > initial_deadline); assert!( - !insert_event(&pool, community, &ephemeral_event, Some(ephemeral)) - .await - .expect("insert duplicate event") - .1 + !insert_event( + &pool, + community, + &ephemeral_event, + Some(ephemeral), + crate::ArchiveGate::Exempt + ) + .await + .expect("insert duplicate event") + .1 ); let duplicate_deadline: DateTime = sqlx::query_scalar( "SELECT ttl_deadline FROM channels WHERE community_id = $1 AND id = $2", @@ -3616,7 +3702,14 @@ mod postgres_tests { let race_pool = pool.clone(); let racing_event = make_text_event("event after stale permanent prefetch"); let insert = tokio::spawn(async move { - insert_event(&race_pool, community, &racing_event, Some(racing)).await + insert_event( + &race_pool, + community, + &racing_event, + Some(racing), + crate::ArchiveGate::Exempt, + ) + .await }); tokio::time::sleep(std::time::Duration::from_millis(100)).await; assert!( @@ -3778,10 +3871,10 @@ mod postgres_tests { .sign_with_keys(&keys) .expect("sign event"); - insert_event(&pool, community_a, &event, None) + insert_event(&pool, community_a, &event, None, crate::ArchiveGate::Exempt) .await .expect("insert in community A"); - insert_event(&pool, community_b, &event, None) + insert_event(&pool, community_b, &event, None, crate::ArchiveGate::Exempt) .await .expect("insert same event in community B"); @@ -3840,19 +3933,37 @@ mod postgres_tests { let base = 1_800_000_000; let older_a = make_event_at(39_000, "older requested A", base + 1); - insert_event(&pool, community, &older_a, Some(channel_a)) - .await - .expect("insert requested A candidate"); + insert_event( + &pool, + community, + &older_a, + Some(channel_a), + crate::ArchiveGate::Exempt, + ) + .await + .expect("insert requested A candidate"); let requested_b = make_event_at(39_000, "requested B", base + 2); - insert_event(&pool, community, &requested_b, Some(channel_b)) - .await - .expect("insert requested B candidate"); + insert_event( + &pool, + community, + &requested_b, + Some(channel_b), + crate::ArchiveGate::Exempt, + ) + .await + .expect("insert requested B candidate"); let newer_c = make_event_at(39_000, "newer unrelated C", base + 3); - insert_event(&pool, community, &newer_c, Some(unrelated_c)) - .await - .expect("insert unrelated C candidate"); + insert_event( + &pool, + community, + &newer_c, + Some(unrelated_c), + crate::ArchiveGate::Exempt, + ) + .await + .expect("insert unrelated C candidate"); let global = make_event_at(39_000, "global candidate", base + 4); - insert_event(&pool, community, &global, None) + insert_event(&pool, community, &global, None, crate::ArchiveGate::Exempt) .await .expect("insert global candidate"); @@ -3906,18 +4017,30 @@ mod postgres_tests { // outnumber the requested page, while the visible match is older. for offset in 10..13 { let event = make_event_at(39_000, "newer inaccessible", base + offset); - insert_event(&pool, community, &event, Some(inaccessible)) - .await - .expect("insert inaccessible candidate"); + insert_event( + &pool, + community, + &event, + Some(inaccessible), + crate::ArchiveGate::Exempt, + ) + .await + .expect("insert inaccessible candidate"); } let global = make_event_at(39_000, "newer global", base + 2); - insert_event(&pool, community, &global, None) + insert_event(&pool, community, &global, None, crate::ArchiveGate::Exempt) .await .expect("insert global candidate"); let older_accessible = make_event_at(39_000, "older accessible", base + 1); - insert_event(&pool, community, &older_accessible, Some(accessible)) - .await - .expect("insert accessible candidate"); + insert_event( + &pool, + community, + &older_accessible, + Some(accessible), + crate::ArchiveGate::Exempt, + ) + .await + .expect("insert accessible candidate"); let events = query_events( &pool, @@ -4097,10 +4220,16 @@ mod postgres_tests { for (content, offset) in [("v1", 0), ("v2", 100)] { assert!( - db.replace_parameterized_event(community, &version(content, offset), d_tag, None) - .await - .expect("store project version") - .1 + db.replace_parameterized_event( + community, + &version(content, offset), + d_tag, + None, + crate::ArchiveGate::Exempt + ) + .await + .expect("store project version") + .1 ); } @@ -4415,6 +4544,7 @@ mod postgres_tests { &event, Uuid::new_v4(), ChannelHeadPrecondition::ExpectNoHead, + crate::ArchiveGate::Exempt, ) .await .expect_err("non-canvas kinds must be rejected"); @@ -4438,6 +4568,7 @@ mod postgres_tests { &event, channel, ChannelHeadPrecondition::ExpectNoHead, + crate::ArchiveGate::Exempt, ) .await .expect("first canvas"); @@ -4457,6 +4588,7 @@ mod postgres_tests { &first, channel, ChannelHeadPrecondition::ExpectNoHead, + crate::ArchiveGate::Exempt, ) .await .expect("first canvas"); @@ -4468,6 +4600,7 @@ mod postgres_tests { &second, channel, ChannelHeadPrecondition::ExpectNoHead, + crate::ArchiveGate::Exempt, ) .await .expect("second create attempt"); @@ -4497,6 +4630,7 @@ mod postgres_tests { &first, channel, ChannelHeadPrecondition::ExpectNoHead, + crate::ArchiveGate::Exempt, ) .await .expect("first canvas"); @@ -4509,6 +4643,7 @@ mod postgres_tests { &second, channel, ChannelHeadPrecondition::ExpectedHead(head.as_slice()), + crate::ArchiveGate::Exempt, ) .await .expect("edit against head"); @@ -4528,6 +4663,7 @@ mod postgres_tests { &first, channel, ChannelHeadPrecondition::ExpectNoHead, + crate::ArchiveGate::Exempt, ) .await .expect("first canvas"); @@ -4540,6 +4676,7 @@ mod postgres_tests { &stale, channel, ChannelHeadPrecondition::ExpectedHead(&wrong_head), + crate::ArchiveGate::Exempt, ) .await .expect("stale edit attempt"); @@ -4561,6 +4698,7 @@ mod postgres_tests { &event, channel, ChannelHeadPrecondition::ExpectedHead(&some_head), + crate::ArchiveGate::Exempt, ) .await .expect("edit with no head"); @@ -4580,6 +4718,7 @@ mod postgres_tests { &event, channel, ChannelHeadPrecondition::ExpectNoHead, + crate::ArchiveGate::Exempt, ) .await .expect("first canvas"); @@ -4592,6 +4731,7 @@ mod postgres_tests { &event, channel, ChannelHeadPrecondition::ExpectedHead(head.as_slice()), + crate::ArchiveGate::Exempt, ) .await .expect("replay head"); @@ -4615,6 +4755,7 @@ mod postgres_tests { &event, channel, ChannelHeadPrecondition::ExpectNoHead, + crate::ArchiveGate::Exempt, ) .await .expect("first canvas"); @@ -4627,6 +4768,7 @@ mod postgres_tests { &event, channel, ChannelHeadPrecondition::ExpectNoHead, + crate::ArchiveGate::Exempt, ) .await .expect("replay with stale none tag"); @@ -4651,6 +4793,7 @@ mod postgres_tests { &lower, channel, ChannelHeadPrecondition::ExpectNoHead, + crate::ArchiveGate::Exempt, ) .await .expect("first canvas is lower-id head"); @@ -4662,6 +4805,7 @@ mod postgres_tests { &higher, channel, ChannelHeadPrecondition::ExpectedHead(lower.id.as_bytes().as_slice()), + crate::ArchiveGate::Exempt, ) .await .expect("same-second higher-id edit"); @@ -4695,6 +4839,7 @@ mod postgres_tests { &higher, channel, ChannelHeadPrecondition::ExpectNoHead, + crate::ArchiveGate::Exempt, ) .await .expect("first canvas is higher-id head"); @@ -4706,6 +4851,7 @@ mod postgres_tests { &lower, channel, ChannelHeadPrecondition::ExpectedHead(higher.id.as_bytes().as_slice()), + crate::ArchiveGate::Exempt, ) .await .expect("same-second lower-id edit"); @@ -4727,6 +4873,7 @@ mod postgres_tests { &head, channel, ChannelHeadPrecondition::ExpectNoHead, + crate::ArchiveGate::Exempt, ) .await .expect("head at t=2000"); @@ -4739,6 +4886,7 @@ mod postgres_tests { &behind, channel, ChannelHeadPrecondition::ExpectedHead(head.id.as_bytes().as_slice()), + crate::ArchiveGate::Exempt, ) .await .expect("behind-clock edit"); @@ -4828,6 +4976,7 @@ mod postgres_tests { &head, channel, ChannelHeadPrecondition::ExpectNoHead, + crate::ArchiveGate::Exempt, ) .await .expect("seed head H"); @@ -4841,6 +4990,7 @@ mod postgres_tests { &a, channel, ChannelHeadPrecondition::ExpectedHead(&head_id), + crate::ArchiveGate::Exempt, ) .await .expect("insert A"); @@ -4874,6 +5024,7 @@ mod postgres_tests { &a, channel, ChannelHeadPrecondition::ExpectedHead(&head_id), + crate::ArchiveGate::Exempt, ) .await .expect("replay A"); @@ -4950,6 +5101,7 @@ mod postgres_tests { &base, channel, ChannelHeadPrecondition::ExpectNoHead, + crate::ArchiveGate::Exempt, ) .await .expect("seed base head"); @@ -4980,6 +5132,7 @@ mod postgres_tests { &a, channel, ChannelHeadPrecondition::ExpectedHead(&id_a), + crate::ArchiveGate::Exempt, ) .await .map(|(stored, status)| (stored.event.id.to_bytes().to_vec(), status)) @@ -4991,6 +5144,7 @@ mod postgres_tests { &b, channel, ChannelHeadPrecondition::ExpectedHead(&id_b), + crate::ArchiveGate::Exempt, ) .await .map(|(stored, status)| (stored.event.id.to_bytes().to_vec(), status)) @@ -5104,6 +5258,7 @@ mod postgres_tests { &a, channel, ChannelHeadPrecondition::ExpectNoHead, + crate::ArchiveGate::Exempt, ) .await .map(|(stored, status)| (stored.event.id.to_bytes().to_vec(), status)) @@ -5115,6 +5270,7 @@ mod postgres_tests { &b, channel, ChannelHeadPrecondition::ExpectNoHead, + crate::ArchiveGate::Exempt, ) .await .map(|(stored, status)| (stored.event.id.to_bytes().to_vec(), status)) @@ -5216,8 +5372,14 @@ mod postgres_tests { let event = make_canvas_event_at("# Untagged", 1000); let write_task = tokio::spawn(async move { - db.insert_event_with_thread_metadata(community, &event, Some(channel), None) - .await + db.insert_event_with_thread_metadata( + community, + &event, + Some(channel), + None, + crate::ArchiveGate::Exempt, + ) + .await }); wait_for_advisory_waiters(&pool, lock_key, 1, std::time::Duration::from_secs(5)).await; @@ -5269,6 +5431,7 @@ mod postgres_tests { &event, channel, ChannelHeadPrecondition::ExpectNoHead, + crate::ArchiveGate::Exempt, ) .await .expect("seed canvas for deletion test"); @@ -5368,7 +5531,7 @@ mod postgres_tests { let event = make_mentioning_event(&mentioned.public_key().to_hex()); let error = db - .insert_event(community, &event, None) + .insert_event(community, &event, None, crate::ArchiveGate::Exempt) .await .expect_err("mention-indexing failure must fail the whole event insert"); assert!( @@ -5399,7 +5562,7 @@ mod postgres_tests { let event = make_mentioning_event(&mentioned.public_key().to_hex()); let (stored, was_inserted) = db - .insert_event(community, &event, None) + .insert_event(community, &event, None, crate::ArchiveGate::Exempt) .await .expect("insert event with mention"); assert!(was_inserted); @@ -5455,6 +5618,7 @@ mod postgres_tests { depth: 0, broadcast: true, }), + crate::ArchiveGate::Exempt, ) .await .expect_err("mention-indexing failure must fail the whole event insert"); @@ -5519,6 +5683,7 @@ mod postgres_tests { depth: 0, broadcast: true, }), + crate::ArchiveGate::Exempt, ) .await .expect("insert thread event with mention"); diff --git a/crates/buzz-db/src/store/event_deletion.rs b/crates/buzz-db/src/store/event_deletion.rs new file mode 100644 index 00000000000..d65a2eb747d --- /dev/null +++ b/crates/buzz-db/src/store/event_deletion.rs @@ -0,0 +1,355 @@ +//! Deletion requests (kind 5 and kind 9005) that store the request and apply +//! it in one transaction. +//! +//! The request's write gates come first (see [`crate::lifecycle`]), so a +//! request into an archived channel stores nothing and deletes nothing. A +//! refusal decided under the locks rolls back the whole request. + +use std::collections::BTreeSet; + +use buzz_core::{CommunityId, StoredEvent}; +use buzz_datastore_tracing::datastore_span; +use chrono::{DateTime, Utc}; +use nostr::Event; +use uuid::Uuid; + +use crate::event::ThreadMetadataParams; +use crate::{AdmittedTx, ArchiveGate, Db, DbError, Result}; + +/// The key of a reaction row: its target event, the target's `created_at`, +/// the reacting pubkey and the emoji. +#[derive(Clone, Copy, Debug)] +pub struct ReactionRowKey<'a> { + /// The reacted-to event id. + pub event_id: &'a [u8], + /// The reacted-to event's `created_at`. + pub event_created_at: DateTime, + /// The reacting pubkey. + pub pubkey: &'a [u8], + /// The reaction emoji. + pub emoji: &'a str, +} + +/// The event a deletion request removes. +#[derive(Clone, Copy, Debug)] +pub struct DeletionTarget<'a> { + /// The target event id. + pub event_id: &'a [u8], + /// Delete the target only if it lives in this channel. A target in any + /// other channel leaves the request stored with no effect (kind 9005). + pub required_channel: Option, + /// Remove a kind:7 target's reaction row too (kind 5): the row that + /// carries the target's id, else the row with this key. + pub reaction: Option>>, +} + +/// The result of [`Db::insert_event_deletion`]. +#[derive(Debug)] +pub struct EventDeletionWrite { + /// The stored deletion request. + pub stored: StoredEvent, + /// `false` when the request was already stored. Nothing changed then. + pub inserted: bool, + /// Whether this request deleted the target. + pub deleted: bool, + /// The target's channel. + pub target_channel: Option, + /// The target's thread root, whose counters the delete changed. + pub root_event_id: Option>, +} + +/// The result of [`Db::insert_coordinate_deletion`]. +#[derive(Debug)] +pub struct CoordinateDeletionWrite { + /// The stored deletion request. + pub stored: StoredEvent, + /// `false` when the request was already stored. Nothing changed then. + pub inserted: bool, + /// Whether this request deleted a version of the coordinate. + pub deleted: bool, +} + +impl Db { + /// Store the deletion request `event` in `channel_id` and soft-delete its + /// target `target` in one transaction: the thread counters, the artifact + /// head and (for kind 5) the reaction row change with it. With no + /// `target` only the request is stored. + /// + /// `gate` decides whether an archived channel refuses the request (see + /// [`ArchiveGate`]): `channel_id` or the channel of a target it deletes. + /// The target's channel never changes, so it is read before the gates. A + /// target that is missing, already deleted, or outside + /// `required_channel` leaves the request stored with no effect. + #[datastore_span(name = "insert_event_deletion", system = "postgresql")] + pub async fn insert_event_deletion( + &self, + community_id: CommunityId, + event: &Event, + channel_id: Option, + thread_meta: Option>, + target: Option>, + gate: ArchiveGate, + ) -> Result { + let mut tx = self.begin_event_write_transaction(community_id).await?; + let target_row = match target { + Some(target) => target_row(&mut tx, target.event_id).await?, + None => None, + }; + // A target outside `required_channel` is not deleted, so its channel + // is not gated: a refusal there would reveal where the target lives. + let target_channel = target_row.and_then(|(_, channel)| channel); + let deletes_target = target.is_some_and(|target| { + target + .required_channel + .is_none_or(|required| target_channel == Some(required)) + }); + let mut gated: BTreeSet = channel_id.into_iter().collect(); + if deletes_target { + gated.extend(target_channel); + } + let gated: Vec = gated.into_iter().collect(); + crate::lifecycle::lock_write_gates(&mut tx, gate, &gated).await?; + + let (stored, inserted) = insert_request(&mut tx, event, channel_id, thread_meta).await?; + let mut write = EventDeletionWrite { + stored, + inserted, + deleted: false, + target_channel, + root_event_id: None, + }; + if !inserted { + tx.rollback().await?; + return Ok(write); + } + let (Some(target), Some((kind, _)), true) = (target, target_row, deletes_target) else { + tx.commit().await?; + return Ok(write); + }; + + let (parent, root) = thread_links(&mut tx, target.event_id).await?; + write.deleted = crate::event::soft_delete_event_and_update_thread_in_tx( + &mut tx, + target.event_id, + parent.as_deref(), + root.as_deref(), + ) + .await?; + if write.deleted { + write.root_event_id = root; + match target.reaction { + Some(fallback) if kind == buzz_core::kind::KIND_REACTION as i32 => { + remove_reaction_row(&mut tx, target.event_id, fallback).await?; + } + _ => {} + } + } + tx.commit().await?; + Ok(write) + } + + /// Store the NIP-09 deletion request `event` for the parameterized + /// coordinate `(kind, pubkey, d_tag)` and soft-delete every version of it + /// created at or before the request, in one transaction. + /// + /// `gate` decides whether an archived channel refuses the request (see + /// [`ArchiveGate`]): `channel_id` or the channel of any version it would + /// delete. The versions are read before the gates and again under the + /// coordinate lock; if one moved to an ungated channel the request is + /// retried once, then fails with [`DbError::LifecycleConflict`]. A + /// version is never skipped. + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "insert_coordinate_deletion", system = "postgresql")] + pub async fn insert_coordinate_deletion( + &self, + community_id: CommunityId, + event: &Event, + channel_id: Option, + thread_meta: Option>, + kind: i32, + pubkey: &[u8], + d_tag: &str, + gate: ArchiveGate, + ) -> Result { + let coordinate = Coordinate { + kind, + pubkey, + d_tag, + }; + match self + .insert_coordinate_deletion_once( + community_id, + event, + channel_id, + thread_meta, + coordinate, + gate, + ) + .await + { + Err(DbError::LifecycleConflict) => { + self.insert_coordinate_deletion_once( + community_id, + event, + channel_id, + thread_meta, + coordinate, + gate, + ) + .await + } + result => result, + } + } + + async fn insert_coordinate_deletion_once( + &self, + community_id: CommunityId, + event: &Event, + channel_id: Option, + thread_meta: Option>, + coordinate: Coordinate<'_>, + gate: ArchiveGate, + ) -> Result { + let created_at_secs = event.created_at.as_secs() as i64; + let cutoff = DateTime::from_timestamp(created_at_secs, 0) + .ok_or(DbError::InvalidTimestamp(created_at_secs))?; + let Coordinate { + kind, + pubkey, + d_tag, + } = coordinate; + let mut tx = self.begin_event_write_transaction(community_id).await?; + let gates = crate::replaceable::lock_coordinate_write_gates( + &mut tx, + gate, + kind, + pubkey, + d_tag, + Some(cutoff), + channel_id.as_slice(), + ) + .await?; + + let lock_key = crate::replaceable::event_replacement_lock_key( + community_id, + kind, + pubkey, + Some(d_tag.as_bytes()), + ); + crate::observability::observe_advisory_lock( + crate::observability::LockType::Replacement, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(tx.conn()), + ) + .await?; + if !gates.is_exempt() { + let channels = crate::replaceable::live_version_channels( + &mut tx, + kind, + pubkey, + d_tag, + Some(cutoff), + ) + .await?; + if !channels.into_iter().all(|channel| gates.covers(channel)) { + return Err(DbError::LifecycleConflict); + } + } + + let (stored, inserted) = insert_request(&mut tx, event, channel_id, thread_meta).await?; + if !inserted { + tx.rollback().await?; + return Ok(CoordinateDeletionWrite { + stored, + inserted, + deleted: false, + }); + } + let deleted = + crate::event::soft_delete_by_coordinate_in_tx(&mut tx, kind, pubkey, d_tag, cutoff) + .await?; + tx.commit().await?; + Ok(CoordinateDeletionWrite { + stored, + inserted, + deleted, + }) + } +} + +/// A parameterized-replaceable coordinate. +#[derive(Clone, Copy)] +struct Coordinate<'a> { + kind: i32, + pubkey: &'a [u8], + d_tag: &'a str, +} + +/// Store the request as `Db::insert_event_with_thread_metadata` does. +async fn insert_request( + tx: &mut AdmittedTx, + event: &Event, + channel_id: Option, + thread_meta: Option>, +) -> Result<(StoredEvent, bool)> { + let (stored, inserted) = + crate::event::insert_event_with_thread_metadata_tx(tx, event, channel_id, thread_meta) + .await?; + if inserted { + crate::insert_mentions_in_transaction(tx, event, channel_id).await?; + } + Ok((stored, inserted)) +} + +/// The target's kind and channel, deleted or not. +async fn target_row(tx: &mut AdmittedTx, event_id: &[u8]) -> Result)>> { + Ok(sqlx::query_as( + "SELECT kind, channel_id FROM events WHERE community_id = $1 AND id = $2 \ + ORDER BY created_at DESC LIMIT 1", + ) + .bind(tx.community().as_uuid()) + .bind(event_id) + .fetch_optional(tx.conn()) + .await?) +} + +/// A thread parent and root event id. +type ThreadLinks = (Option>, Option>); + +/// The target's thread parent and root, whose counters a delete lowers. +async fn thread_links(tx: &mut AdmittedTx, event_id: &[u8]) -> Result { + let links: Option = sqlx::query_as( + "SELECT parent_event_id, root_event_id FROM thread_metadata \ + WHERE community_id = $1 AND event_id = $2 LIMIT 1", + ) + .bind(tx.community().as_uuid()) + .bind(event_id) + .fetch_optional(tx.conn()) + .await?; + Ok(links.unwrap_or_default()) +} + +/// Remove the reaction row of the deleted kind:7 `reaction_event_id`: the row +/// that carries its id, else the row with `fallback`'s key. +async fn remove_reaction_row( + tx: &mut AdmittedTx, + reaction_event_id: &[u8], + fallback: Option>, +) -> Result<()> { + if crate::reaction::remove_reaction_by_source_event_id_tx(tx, reaction_event_id).await? { + return Ok(()); + } + if let Some(key) = fallback { + crate::reaction::remove_reaction_tx( + tx, + key.event_id, + key.event_created_at, + key.pubkey, + key.emoji, + ) + .await?; + } + Ok(()) +} diff --git a/crates/buzz-db/src/store/event_follow_up_postgres_tests.rs b/crates/buzz-db/src/store/event_follow_up_postgres_tests.rs index e6beb22b514..3273be5c506 100644 --- a/crates/buzz-db/src/store/event_follow_up_postgres_tests.rs +++ b/crates/buzz-db/src/store/event_follow_up_postgres_tests.rs @@ -303,10 +303,15 @@ async fn assert_contract(migration_schema: bool, arm: Arm) { let deadline_before_first_insert = reset_deadline(pool, community, channel).await; let message = signed_event(&keys, kind, &format!("allowlisted-{kind}")); assert!( - db.insert_event(community, &message, Some(channel)) - .await - .expect("insert allowlisted event") - .1 + db.insert_event( + community, + &message, + Some(channel), + crate::ArchiveGate::Exempt + ) + .await + .expect("insert allowlisted event") + .1 ); assert_eq!(event_count(pool, community, &message).await, 1); assert_eq!(match_count(&db, community, &message).await, 1); @@ -316,10 +321,15 @@ async fn assert_contract(migration_schema: bool, arm: Arm) { "first insertion of kind {kind} must refresh the channel TTL" ); assert!( - !db.insert_event(community, &message, Some(channel)) - .await - .expect("deduplicate event") - .1 + !db.insert_event( + community, + &message, + Some(channel), + crate::ArchiveGate::Exempt + ) + .await + .expect("deduplicate event") + .1 ); assert_eq!(match_count(&db, community, &message).await, 1); assert_eq!( @@ -331,20 +341,30 @@ async fn assert_contract(migration_schema: bool, arm: Arm) { let reaction = signed_event(&keys, 7, "not-push-eligible"); assert!( - db.insert_event(community, &reaction, Some(channel)) - .await - .expect("insert reaction") - .1 + db.insert_event( + community, + &reaction, + Some(channel), + crate::ArchiveGate::Exempt + ) + .await + .expect("insert reaction") + .1 ); assert_eq!(match_count(&db, community, &reaction).await, 0); let no_lease_community = create_community(pool).await; let no_lease_message = signed_event(&keys, 9, "no-lease"); assert!( - db.insert_event(no_lease_community, &no_lease_message, None) - .await - .expect("insert without lease") - .1 + db.insert_event( + no_lease_community, + &no_lease_message, + None, + crate::ArchiveGate::Exempt + ) + .await + .expect("insert without lease") + .1 ); assert_eq!( match_count(&db, no_lease_community, &no_lease_message).await, @@ -393,10 +413,15 @@ async fn assert_contract(migration_schema: bool, arm: Arm) { } let message = signed_event(&keys, 9, &format!("{state}-lease")); assert!( - db.insert_event(predicate_community, &message, None) - .await - .expect("insert event for ineligible lease") - .1 + db.insert_event( + predicate_community, + &message, + None, + crate::ArchiveGate::Exempt + ) + .await + .expect("insert event for ineligible lease") + .1 ); assert_eq!(event_count(pool, predicate_community, &message).await, 1); assert_eq!(match_count(&db, predicate_community, &message).await, 0); @@ -449,7 +474,7 @@ async fn assert_contract(migration_schema: bool, arm: Arm) { .expect("release exclusive push-gate lock"); let gated_control = signed_event(&keys, 9, "push-gate-released-control"); assert!( - db.insert_event(community, &gated_control, None) + db.insert_event(community, &gated_control, None, crate::ArchiveGate::Exempt) .await .expect("insert after push gate release") .1 @@ -518,16 +543,21 @@ async fn assert_contract(migration_schema: bool, arm: Arm) { let before_skips = reset_deadline(pool, community, channel).await; let channel_create = signed_event(&keys, 9007, "channel-create"); assert!( - db.insert_event(community, &channel_create, Some(channel)) - .await - .expect("insert kind 9007") - .1 + db.insert_event( + community, + &channel_create, + Some(channel), + crate::ArchiveGate::Exempt + ) + .await + .expect("insert kind 9007") + .1 ); assert_eq!(deadline(pool, community, channel).await, before_skips); assert_eq!(match_count(&db, community, &channel_create).await, 0); let channelless = signed_event(&keys, 9, "channel-null"); assert!( - db.insert_event(community, &channelless, None) + db.insert_event(community, &channelless, None, crate::ArchiveGate::Exempt) .await .expect("insert channel-less event") .1 @@ -538,10 +568,15 @@ async fn assert_contract(migration_schema: bool, arm: Arm) { channel_with_ttl(pool, community, &keys.public_key().to_bytes(), None).await; let no_ttl_message = signed_event(&keys, 7, "ttl-null"); assert!( - db.insert_event(community, &no_ttl_message, Some(no_ttl_channel)) - .await - .expect("insert into channel without TTL") - .1 + db.insert_event( + community, + &no_ttl_message, + Some(no_ttl_channel), + crate::ArchiveGate::Exempt + ) + .await + .expect("insert into channel without TTL") + .1 ); assert_eq!( optional_deadline(pool, community, no_ttl_channel).await, @@ -570,10 +605,15 @@ async fn assert_contract(migration_schema: bool, arm: Arm) { .expect("mark disposable channel inactive"); let message = signed_event(&keys, 7, &format!("{marker}-channel")); assert!( - db.insert_event(community, &message, Some(inactive_channel)) - .await - .expect("insert against inactive disposable channel") - .1 + db.insert_event( + community, + &message, + Some(inactive_channel), + crate::ArchiveGate::Exempt + ) + .await + .expect("insert against inactive disposable channel") + .1 ); assert_eq!(event_count(pool, community, &message).await, 1); assert_eq!( @@ -668,10 +708,16 @@ async fn assert_entry_points(db: &Db, community: CommunityId, channel: Uuid, key let before = reset_deadline(pool, community, channel).await; let threaded = signed_event(keys, 9, "thread-metadata-writer"); assert!( - db.insert_event_with_thread_metadata(community, &threaded, Some(channel), None) - .await - .expect("insert through the thread-metadata writer") - .1 + db.insert_event_with_thread_metadata( + community, + &threaded, + Some(channel), + None, + crate::ArchiveGate::Exempt + ) + .await + .expect("insert through the thread-metadata writer") + .1 ); assert_eq!(match_count(db, community, &threaded).await, 1); assert!(deadline(pool, community, channel).await > before); @@ -696,10 +742,15 @@ async fn assert_entry_points(db: &Db, community: CommunityId, channel: Uuid, key let before = reset_deadline(pool, community, channel).await; let replaceable = signed_event(keys, 39000, "replaceable-writer"); assert!( - db.replace_addressable_event(community, &replaceable, Some(channel)) - .await - .expect("insert through the replaceable writer") - .1 + db.replace_addressable_event( + community, + &replaceable, + Some(channel), + crate::ArchiveGate::Exempt + ) + .await + .expect("insert through the replaceable writer") + .1 ); assert_eq!(match_count(db, community, &replaceable).await, 0); assert!(deadline(pool, community, channel).await > before); @@ -720,6 +771,7 @@ async fn assert_entry_points(db: &Db, community: CommunityId, channel: Uuid, key &d_tag, Some(channel), ParameterizedReplacePrecondition::Unconditional, + &crate::WriteGates::exempt(), ) .await .expect("insert through the parameterized writer"); @@ -736,6 +788,7 @@ async fn assert_entry_points(db: &Db, community: CommunityId, channel: Uuid, key &d_tag, Some(channel), ParameterizedReplacePrecondition::Unconditional, + &crate::WriteGates::exempt(), ) .await .expect("replay through the parameterized writer"); @@ -827,9 +880,14 @@ async fn migration_schema_event_follow_up_contract_app_only() { async fn assert_rollback(db: &Db, community: CommunityId, channel: Uuid, keys: &Keys) { let pool = db.pool(); let retained = signed_event(keys, 9, "queued-before-rollback"); - db.insert_event(community, &retained, Some(channel)) - .await - .unwrap(); + db.insert_event( + community, + &retained, + Some(channel), + crate::ArchiveGate::Exempt, + ) + .await + .unwrap(); assert_eq!(match_count(db, community, &retained).await, 1); let lease_before: serde_json::Value = sqlx::query_scalar("SELECT to_jsonb(p) FROM push_leases p WHERE community_id=$1") @@ -895,7 +953,12 @@ async fn assert_rollback(db: &Db, community: CommunityId, channel: Uuid, keys: & let message = signed_event(keys, kind, &format!("disabled-{kind}")); timeout( Duration::from_secs(5), - disabled.insert_event(community, &message, Some(channel)), + disabled.insert_event( + community, + &message, + Some(channel), + crate::ArchiveGate::Exempt, + ), ) .await .expect("disabled write must not wait for the push gate") @@ -939,7 +1002,12 @@ async fn assert_rollback(db: &Db, community: CommunityId, channel: Uuid, keys: & let legacy = Db::from_pool(legacy_pool); let message = signed_event(keys, 9, "legacy-overlap-writer"); legacy - .insert_event(community, &message, Some(channel)) + .insert_event( + community, + &message, + Some(channel), + crate::ArchiveGate::Exempt, + ) .await .unwrap(); let trigger_exists: bool = sqlx::query_scalar( @@ -957,9 +1025,14 @@ async fn assert_rollback(db: &Db, community: CommunityId, channel: Uuid, keys: & // Reactivation is an operator decision about retained queues. Reopening // an enabled writer produces only new work, with no lease backfill. let message = signed_event(keys, 9, "new-after-reactivation"); - db.insert_event(community, &message, Some(channel)) - .await - .unwrap(); + db.insert_event( + community, + &message, + Some(channel), + crate::ArchiveGate::Exempt, + ) + .await + .unwrap(); assert_eq!(match_count(db, community, &message).await, 1); for message in disabled_events { assert_eq!(match_count(db, community, &message).await, 0); @@ -988,7 +1061,7 @@ async fn assert_async_isolation(db: &Db, community: CommunityId, keys: &Keys) { let message = signed_event(keys, 9, "async-queue-lock"); timeout( Duration::from_secs(5), - isolated.insert_event(community, &message, None), + isolated.insert_event(community, &message, None, crate::ArchiveGate::Exempt), ) .await .expect("push table lock must not delay storage") @@ -1001,7 +1074,7 @@ async fn assert_async_isolation(db: &Db, community: CommunityId, keys: &Keys) { assert_eq!(event_count(isolated.pool(), community, &message).await, 1); let healthy = signed_event(keys, 9, "async-after-error"); isolated - .insert_event(community, &healthy, None) + .insert_event(community, &healthy, None, crate::ArchiveGate::Exempt) .await .unwrap(); assert_eq!(match_count(&isolated, community, &healthy).await, 1); @@ -1018,7 +1091,7 @@ async fn assert_async_isolation(db: &Db, community: CommunityId, keys: &Keys) { .unwrap(); let before_enrollment = signed_event(keys, 9, "before-async-enrollment"); isolated - .insert_event(later, &before_enrollment, None) + .insert_event(later, &before_enrollment, None, crate::ArchiveGate::Exempt) .await .unwrap(); sqlx::query( @@ -1065,7 +1138,7 @@ async fn assert_async_isolation(db: &Db, community: CommunityId, keys: &Keys) { .unwrap(); let cancelled = signed_event(keys, 9, "async-cancel-inflight"); isolated - .insert_event(community, &cancelled, None) + .insert_event(community, &cancelled, None, crate::ArchiveGate::Exempt) .await .unwrap(); timeout(Duration::from_secs(2), async { @@ -1103,7 +1176,7 @@ async fn assert_async_isolation(db: &Db, community: CommunityId, keys: &Keys) { assert_eq!(queued, 0); let after_stop = signed_event(keys, 9, "async-message-after-stop"); isolated - .insert_event(community, &after_stop, None) + .insert_event(community, &after_stop, None, crate::ArchiveGate::Exempt) .await .unwrap(); assert_eq!( diff --git a/crates/buzz-db/src/store/feed.rs b/crates/buzz-db/src/store/feed.rs index 1094c948e82..e6aee066a24 100644 --- a/crates/buzz-db/src/store/feed.rs +++ b/crates/buzz-db/src/store/feed.rs @@ -648,9 +648,15 @@ mod postgres_tests { .tags(tags) .sign_with_keys(&keys) .expect("sign event"); - crate::event::insert_event(pool, community, &event, channel_id) - .await - .expect("insert feed event"); + crate::event::insert_event( + pool, + community, + &event, + channel_id, + crate::ArchiveGate::Exempt, + ) + .await + .expect("insert feed event"); crate::insert_mentions(pool, community, &event, channel_id) .await .expect("insert mentions"); diff --git a/crates/buzz-db/src/store/lifecycle.rs b/crates/buzz-db/src/store/lifecycle.rs index c7a089524ae..140acb6f4f6 100644 --- a/crates/buzz-db/src/store/lifecycle.rs +++ b/crates/buzz-db/src/store/lifecycle.rs @@ -1,19 +1,28 @@ //! Guarded task archive and restore, the `nexus.task` hierarchy projection, //! and the authoritative lifecycle state read. //! -//! Every writer here takes its locks in this order and never takes an +//! Every channel writer takes its locks in this order and never takes an //! earlier lock after a later one: //! //! 1. community admission (the transaction begin); //! 2. task family locks, sorted by family root; -//! 3. channel gates, exclusive, sorted by channel; -//! 4. channel TTL locks, shared, sorted by channel; -//! 5. channel rows, sorted by channel; -//! 6. the artifact coordinate lock, then the artifact head. +//! 3. channel gates, sorted by channel: EXCLUSIVE for a writer that changes +//! archive state, SHARED for a write that [`crate::ArchiveGate::Refuse`] guards; +//! 4. channel membership locks, sorted by channel; +//! 5. channel TTL locks, sorted by channel; +//! 6. channel rows, sorted by channel; +//! 7. the record, canvas or replacement coordinate lock, then its head. //! -//! A family is keyed by its root. Every edge write takes the family locks of -//! the child's and the new parent's roots, so a holder of a root's lock sees -//! a stable topology for that family. +//! A writer skips the steps it does not need. A family is keyed by its root. +//! Every edge write takes the family locks of the child's and the new +//! parent's roots, so a holder of a root's lock sees a stable topology for +//! that family. +//! +//! The gate makes "archived" final for refused writes. An archive writer +//! holds the gate exclusive until it commits. A refused write takes the gate +//! shared as the first lock of its own transaction and then re-reads +//! `archived_at` in a new statement. So the write either commits before the +//! archive starts, or it sees the archive and stores nothing. use crate::{AdmittedTx, DbError, Result}; use buzz_core::lifecycle::{lifecycle_digest, LifecycleScope, TokenEntry}; use buzz_core::{CommunityId, StoredEvent}; @@ -48,6 +57,114 @@ pub(crate) fn gate_lock_key(community: CommunityId, channel: Uuid) -> String { format!("buzz_channel_gate:{}:{channel}", community.as_uuid()) } +/// The refusal for a write into an archived channel, as +/// [`DbError::AccessDenied`]. +pub const CHANNEL_ARCHIVED: &str = "channel is archived"; + +/// The archive policy of one channel write. Every function that writes +/// into a channel takes one; there is no default. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ArchiveGate { + /// The write is refused in an archived channel. It takes each channel's + /// gate SHARED as the first lock of its transaction, then re-reads + /// `archived_at` and fails with [`DbError::AccessDenied`] + /// ([`CHANNEL_ARCHIVED`]) if any channel is archived. Client writes use + /// this. + Refuse, + /// The write is allowed in an archived channel and takes no gate: relay + /// notices, a member leaving by themselves, per-user view state and + /// operator moderation. + Exempt, +} + +/// The write gates one transaction holds, from [`lock_write_gates`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WriteGates { + gate: ArchiveGate, + channels: BTreeSet, +} + +impl WriteGates { + /// No gates: an [`ArchiveGate::Exempt`] write. + pub fn exempt() -> Self { + Self { + gate: ArchiveGate::Exempt, + channels: BTreeSet::new(), + } + } + + /// Whether the write is [`ArchiveGate::Exempt`]. + pub fn is_exempt(&self) -> bool { + self.gate == ArchiveGate::Exempt + } + + /// Whether this transaction may write into `channel`: the write is + /// exempt, or it holds `channel`'s gate. + pub fn covers(&self, channel: Uuid) -> bool { + self.gate == ArchiveGate::Exempt || self.channels.contains(&channel) + } +} + +/// Take the write gates of `channels` on `tx` under `gate`, then refuse if +/// any of them is archived. [`ArchiveGate::Exempt`] takes nothing. +/// +/// Call this as the first lock of the write transaction (after the family +/// locks of a task record), and only once: a gate taken later in the same +/// transaction could deadlock with an archive writer. A +/// transaction-scoped primitive, such as +/// [`crate::event::insert_event_in_transaction`], takes no gate: its +/// transaction owner calls this first. +pub async fn lock_write_gates( + tx: &mut AdmittedTx, + gate: ArchiveGate, + channels: &[Uuid], +) -> Result { + if gate == ArchiveGate::Exempt { + return Ok(WriteGates::exempt()); + } + let channels: BTreeSet = channels.iter().copied().collect(); + lock_shared_gates(tx, &channels).await?; + if !archived_among(tx, &channels).await?.is_empty() { + return Err(DbError::AccessDenied(CHANNEL_ARCHIVED.into())); + } + Ok(WriteGates { gate, channels }) +} + +/// Take the gates of `channels` SHARED, sorted, in one statement. +pub(crate) async fn lock_shared_gates( + tx: &mut AdmittedTx, + channels: &BTreeSet, +) -> Result<()> { + let community = tx.community(); + let keys: Vec<_> = channels + .iter() + .map(|channel| gate_lock_key(community, *channel)) + .collect(); + advisory_locks(tx, &keys, true).await +} + +/// The archived channels among `channels`. Run it as its own statement +/// after the gates, so it reads every archive committed before them. +pub(crate) async fn archived_among( + tx: &mut AdmittedTx, + channels: &BTreeSet, +) -> Result> { + if channels.is_empty() { + return Ok(BTreeSet::new()); + } + let community = tx.community(); + let ids: Vec = channels.iter().copied().collect(); + let archived: Vec = sqlx::query_scalar( + "SELECT id FROM channels \ + WHERE community_id = $1 AND id = ANY($2) AND archived_at IS NOT NULL", + ) + .bind(community.as_uuid()) + .bind(&ids) + .fetch_all(tx.conn()) + .await?; + Ok(archived.into_iter().collect()) +} + /// Take transaction-scoped advisory locks on `keys`, in order, in one /// statement. async fn advisory_locks(tx: &mut AdmittedTx, keys: &[String], shared: bool) -> Result<()> { @@ -74,7 +191,9 @@ async fn lock_families(tx: &mut AdmittedTx, roots: &BTreeSet) -> Result<() advisory_locks(tx, &keys, false).await } -async fn lock_gates(tx: &mut AdmittedTx, channels: &BTreeSet) -> Result<()> { +/// Take the gates of `channels` EXCLUSIVE, sorted, in one statement. Every +/// writer that changes archive state holds them until it commits. +pub(crate) async fn lock_gates(tx: &mut AdmittedTx, channels: &BTreeSet) -> Result<()> { let community = tx.community(); let keys: Vec<_> = channels .iter() @@ -663,14 +782,16 @@ pub(crate) struct EdgeLocks { } /// Lock what a `nexus.task` revision for `child` with parent `parent` -/// needs: the families of both, the TTL locks and the rows (`FOR SHARE`). -/// Returns `None` when a family root moved before its lock was taken; the -/// caller retries once. +/// needs: the families of both, the write gates `gates` (shared, empty for +/// an exempt write), the TTL locks and the rows (`FOR SHARE`). Returns `None` +/// when a family root moved before its lock was taken; the caller retries +/// once. pub(crate) async fn lock_task_edge( tx: &mut AdmittedTx, child: Uuid, parent: Option, author: &[u8], + gates: &BTreeSet, ) -> Result> { let roots = |child_walk: &Walk, parent_walk: Option<&Walk>| { let mut roots = BTreeSet::from([child_walk.root(child)]); @@ -694,6 +815,7 @@ pub(crate) async fn lock_task_edge( if roots(&child_walk, parent_walk.as_ref()) != locked { return Ok(None); } + lock_shared_gates(tx, gates).await?; let mut channels = BTreeSet::from([child]); channels.extend(parent); lock_ttls_shared(tx, &channels).await?; diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index 7f6957b2bc7..177d9d65b73 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -22,6 +22,8 @@ pub mod dm; pub mod entity_deletion; /// Event storage and retrieval. pub mod event; +/// Deletion requests stored and applied in one transaction. +pub mod event_deletion; /// App-owned push enqueue and channel TTL refresh for inserted events. pub(crate) mod event_follow_up; /// Guarded task archive and restore, the task projection and the state read. diff --git a/crates/buzz-db/src/store/operator_listener.rs b/crates/buzz-db/src/store/operator_listener.rs index 08983b36d2e..17024442f8e 100644 --- a/crates/buzz-db/src/store/operator_listener.rs +++ b/crates/buzz-db/src/store/operator_listener.rs @@ -595,10 +595,15 @@ mod postgres_tests { .tag(Tag::public_key(target.public_key())) .sign_with_keys(&Keys::generate()) .expect("sign test event"); - let (_, inserted) = - crate::event::insert_event(&pool, CommunityId::from_uuid(community_id), &event, None) - .await - .expect("insert event"); + let (_, inserted) = crate::event::insert_event( + &pool, + CommunityId::from_uuid(community_id), + &event, + None, + crate::ArchiveGate::Exempt, + ) + .await + .expect("insert event"); assert!(inserted); let outbox_count: i64 = sqlx::query_scalar( @@ -685,10 +690,16 @@ mod postgres_tests { .sign_with_keys(&Keys::generate()) .expect("sign test event"); let community = CommunityId::from_uuid(community_id); - let (_, inserted) = - crate::event::insert_event_with_thread_metadata(&pool, community, &event, None, None) - .await - .expect("insert event through thread-metadata path"); + let (_, inserted) = crate::event::insert_event_with_thread_metadata( + &pool, + community, + &event, + None, + None, + crate::ArchiveGate::Exempt, + ) + .await + .expect("insert event through thread-metadata path"); assert!(inserted); let delivered_listeners: Vec> = sqlx::query_scalar( @@ -706,10 +717,16 @@ mod postgres_tests { expected_listeners.sort(); assert_eq!(delivered_listeners, expected_listeners); - let (_, duplicate_inserted) = - crate::event::insert_event_with_thread_metadata(&pool, community, &event, None, None) - .await - .expect("retry duplicate event through thread-metadata path"); + let (_, duplicate_inserted) = crate::event::insert_event_with_thread_metadata( + &pool, + community, + &event, + None, + None, + crate::ArchiveGate::Exempt, + ) + .await + .expect("retry duplicate event through thread-metadata path"); assert!(!duplicate_inserted); let outbox_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM operator_listener_outbox WHERE community_id = $1 AND event_id = $2", @@ -1223,9 +1240,15 @@ mod postgres_tests { .tag(Tag::public_key(target.public_key())) .sign_with_keys(&author) .expect("sign test event"); - crate::event::insert_event(&pool, community, &event, channel) - .await - .expect("insert event"); + crate::event::insert_event( + &pool, + community, + &event, + channel, + crate::ArchiveGate::Exempt, + ) + .await + .expect("insert event"); deliveries.insert( channel, ClaimedDelivery { diff --git a/crates/buzz-db/src/store/personal_read/arrival_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/arrival_postgres_tests.rs index a36c2d3c9c4..c09ee3cbca6 100644 --- a/crates/buzz-db/src/store/personal_read/arrival_postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/arrival_postgres_tests.rs @@ -38,7 +38,7 @@ async fn post( .custom_created_at(nostr::Timestamp::from(authored)) .sign_with_keys(&Keys::generate()) .unwrap(); - db.insert_event(community, &event, Some(channel)) + db.insert_event(community, &event, Some(channel), crate::ArchiveGate::Exempt) .await .unwrap(); arrive(pool, community, &event, arrived).await; @@ -254,9 +254,14 @@ async fn mark_as_read_behind_fillers(fillers: u64) -> (bool, u32) { .custom_created_at(nostr::Timestamp::from(now - 500 + i)) .sign_with_keys(&Keys::generate()) .unwrap(); - db.insert_event(community, &filler, Some(channel)) - .await - .unwrap(); + db.insert_event( + community, + &filler, + Some(channel), + crate::ArchiveGate::Exempt, + ) + .await + .unwrap(); arrive(&pool, community, &filler, now - 50).await; } post(&db, &pool, community, channel, &actor, now - 600, now - 10).await; @@ -306,9 +311,14 @@ async fn latest_message_behind_a_full_probe_of_reactions_is_found() { .custom_created_at(nostr::Timestamp::from(now - 500 + i)) .sign_with_keys(&Keys::generate()) .unwrap(); - db.insert_event(community, &filler, Some(channel)) - .await - .unwrap(); + db.insert_event( + community, + &filler, + Some(channel), + crate::ArchiveGate::Exempt, + ) + .await + .unwrap(); arrive(&pool, community, &filler, now - 50).await; } diff --git a/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs index 5a3d3a0acad..931166013ad 100644 --- a/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/participation_postgres_tests.rs @@ -71,7 +71,12 @@ impl World { .sign_with_keys(author) .unwrap(); self.db - .insert_event(self.community, &event, Some(channel)) + .insert_event( + self.community, + &event, + Some(channel), + crate::ArchiveGate::Exempt, + ) .await .unwrap(); event diff --git a/crates/buzz-db/src/store/personal_read/postgres_tests.rs b/crates/buzz-db/src/store/personal_read/postgres_tests.rs index 99da1ff848c..fdf34d65e97 100644 --- a/crates/buzz-db/src/store/personal_read/postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/postgres_tests.rs @@ -35,7 +35,7 @@ pub(super) async fn fixture() -> (Db, PgPool, CommunityId, Uuid, Keys, nostr::Ev let event = EventBuilder::new(Kind::Custom(9), "read this, not its sibling") .sign_with_keys(&Keys::generate()) .unwrap(); - db.insert_event(community, &event, Some(channel)) + db.insert_event(community, &event, Some(channel), crate::ArchiveGate::Exempt) .await .unwrap(); start_before_everything(&pool, community, &actor.public_key()).await; @@ -122,7 +122,7 @@ async fn add_history(db: &Db, community: CommunityId, channel: Uuid, count: usiz .custom_created_at(nostr::Timestamp::from(base + i)) .sign_with_keys(&author) .unwrap(); - db.insert_event(community, &event, Some(channel)) + db.insert_event(community, &event, Some(channel), crate::ArchiveGate::Exempt) .await .unwrap(); last = Some(event); @@ -173,7 +173,7 @@ async fn personal_read_sidebar_author_window_excludes_expired_and_late_old_messa .custom_created_at(nostr::Timestamp::from(now - age)) .sign_with_keys(&Keys::generate()) .unwrap(); - db.insert_event(community, &event, Some(channel)) + db.insert_event(community, &event, Some(channel), crate::ArchiveGate::Exempt) .await .unwrap(); let row = &sidebar(&db, community, &actor).await.channels[0]; @@ -196,9 +196,14 @@ async fn personal_read_sidebar_window_budget_counts_boundary_and_ineligible_tail let overflow = EventBuilder::new(Kind::Custom(9), "one beyond the budget") .sign_with_keys(&Keys::generate()) .unwrap(); - db.insert_event(community, &overflow, Some(channel)) - .await - .unwrap(); + db.insert_event( + community, + &overflow, + Some(channel), + crate::ArchiveGate::Exempt, + ) + .await + .unwrap(); mention_everywhere(&pool, community, &actor).await; // The budget bounds the count; the unexamined message is left out. assert_eq!( @@ -290,7 +295,12 @@ async fn personal_read_intent_does_not_lock_shared_conversation_rows() { .unwrap(); tokio::time::timeout( std::time::Duration::from_secs(2), - db.insert_event(community, &incoming, Some(channel)), + db.insert_event( + community, + &incoming, + Some(channel), + crate::ArchiveGate::Exempt, + ), ) .await .unwrap() @@ -327,7 +337,7 @@ async fn personal_read_diff_alone_leaves_the_sidebar_row_unchanged() { .tags([nostr::Tag::parse(["p", &actor.public_key().to_hex()]).unwrap()]) .sign_with_keys(&Keys::generate()) .unwrap(); - db.insert_event(community, &event, Some(channel)) + db.insert_event(community, &event, Some(channel), crate::ArchiveGate::Exempt) .await .unwrap(); } @@ -347,7 +357,7 @@ async fn personal_read_latest_includes_own_and_excludes_deleted_auxiliary() { .custom_created_at(nostr::Timestamp::from(base + 1)) .sign_with_keys(&actor) .unwrap(); - db.insert_event(community, &own, Some(channel)) + db.insert_event(community, &own, Some(channel), crate::ArchiveGate::Exempt) .await .unwrap(); for (offset, kind) in [(2, 9), (4, 7)] { @@ -355,7 +365,7 @@ async fn personal_read_latest_includes_own_and_excludes_deleted_auxiliary() { .custom_created_at(nostr::Timestamp::from(base + offset)) .sign_with_keys(&actor) .unwrap(); - db.insert_event(community, &event, Some(channel)) + db.insert_event(community, &event, Some(channel), crate::ArchiveGate::Exempt) .await .unwrap(); if offset == 2 { @@ -392,9 +402,14 @@ async fn personal_read_latest_skips_a_reply_without_ancestry() { .custom_created_at(nostr::Timestamp::from(first.created_at.as_secs() + 1)) .sign_with_keys(&Keys::generate()) .unwrap(); - db.insert_event(community, &orphan, Some(channel)) - .await - .unwrap(); + db.insert_event( + community, + &orphan, + Some(channel), + crate::ArchiveGate::Exempt, + ) + .await + .unwrap(); let latest = |page: SidebarPage| page.channels[0].latest_id.clone(); assert_eq!( latest(sidebar(&db, community, &actor).await), @@ -498,7 +513,7 @@ async fn personal_read_frontier_is_monotonic_and_rejects_malformed_anchors() { .custom_created_at(nostr::Timestamp::from(event.created_at.as_secs() - 10)) .sign_with_keys(&Keys::generate()) .unwrap(); - db.insert_event(community, &later, Some(channel)) + db.insert_event(community, &later, Some(channel), crate::ArchiveGate::Exempt) .await .unwrap(); for (anchor, why) in [ @@ -665,9 +680,14 @@ async fn personal_read_counts_only_arrivals_after_the_account_starts() { let elsewhere = EventBuilder::new(Kind::Custom(9), "elsewhere") .sign_with_keys(&Keys::generate()) .unwrap(); - db.insert_event(community, &elsewhere, Some(other)) - .await - .unwrap(); + db.insert_event( + community, + &elsewhere, + Some(other), + crate::ArchiveGate::Exempt, + ) + .await + .unwrap(); db.apply_personal_read_intent( community, &actor.public_key(), @@ -686,7 +706,7 @@ async fn personal_read_counts_only_arrivals_after_the_account_starts() { .tags([Tag::public_key(actor.public_key())]) .sign_with_keys(&Keys::generate()) .unwrap(); - db.insert_event(community, &later, Some(channel)) + db.insert_event(community, &later, Some(channel), crate::ArchiveGate::Exempt) .await .unwrap(); assert_eq!( @@ -727,7 +747,7 @@ async fn personal_read_channel_and_thread_never_inherit_each_other() { .custom_created_at(nostr::Timestamp::from(at)) .sign_with_keys(&Keys::generate()) .unwrap(); - db.insert_event(community, &reply, Some(channel)) + db.insert_event(community, &reply, Some(channel), crate::ArchiveGate::Exempt) .await .unwrap(); sqlx::query("INSERT INTO thread_metadata (community_id,event_id,event_created_at,channel_id,root_event_id,parent_event_id,depth) @@ -742,7 +762,7 @@ async fn personal_read_channel_and_thread_never_inherit_each_other() { .custom_created_at(nostr::Timestamp::from(base + 20)) .sign_with_keys(&Keys::generate()) .unwrap(); - db.insert_event(community, &top, Some(channel)) + db.insert_event(community, &top, Some(channel), crate::ArchiveGate::Exempt) .await .unwrap(); // Arrives last, and still is not the timeline's latest. @@ -834,7 +854,7 @@ async fn personal_read_author_horizon_unresolved_ancestry_and_deletion() { .tags([mention.clone()]) .sign_with_keys(&Keys::generate()) .unwrap(); - db.insert_event(community, &old, Some(channel)) + db.insert_event(community, &old, Some(channel), crate::ArchiveGate::Exempt) .await .unwrap(); // Claims to reply, with no recorded ancestry: left out, though directed. @@ -845,9 +865,14 @@ async fn personal_read_author_horizon_unresolved_ancestry_and_deletion() { ]) .sign_with_keys(&Keys::generate()) .unwrap(); - db.insert_event(community, &unresolved, Some(channel)) - .await - .unwrap(); + db.insert_event( + community, + &unresolved, + Some(channel), + crate::ArchiveGate::Exempt, + ) + .await + .unwrap(); let row = &sidebar(&db, community, &actor).await.channels[0]; assert_eq!((row.unread, row.mentions), (true, 1)); assert!(row.threads.is_empty()); diff --git a/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs b/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs index 2bb86aab8da..76427d81a5f 100644 --- a/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/threads_postgres_tests.rs @@ -32,7 +32,7 @@ async fn post_as( .custom_created_at(nostr::Timestamp::from(at)) .sign_with_keys(author) .unwrap(); - db.insert_event(community, &event, Some(channel)) + db.insert_event(community, &event, Some(channel), crate::ArchiveGate::Exempt) .await .unwrap(); event @@ -138,9 +138,14 @@ async fn mark_through_rejects_unresolved_missing_auxiliary_and_malformed_anchors let reaction = EventBuilder::new(Kind::Custom(7), "+") .sign_with_keys(&Keys::generate()) .unwrap(); - db.insert_event(community, &reaction, Some(channel)) - .await - .unwrap(); + db.insert_event( + community, + &reaction, + Some(channel), + crate::ArchiveGate::Exempt, + ) + .await + .unwrap(); for (anchor, outcome) in [ ("cd".repeat(32), IntentOutcome::Blocked), (reaction.id.to_hex(), IntentOutcome::Blocked), @@ -314,7 +319,7 @@ async fn thread_on_a_never_unread_root_is_selectable_and_readable_by_itself() { .custom_created_at(nostr::Timestamp::from(base + 1)) .sign_with_keys(&Keys::generate()) .unwrap(); - db.insert_event(community, &diff, Some(channel)) + db.insert_event(community, &diff, Some(channel), crate::ArchiveGate::Exempt) .await .unwrap(); let on_diff = reply(&db, &pool, community, channel, &diff, base + 10, mention()).await; diff --git a/crates/buzz-db/src/store/push.rs b/crates/buzz-db/src/store/push.rs index ce3a734a43a..514709403e5 100644 --- a/crates/buzz-db/src/store/push.rs +++ b/crates/buzz-db/src/store/push.rs @@ -1637,7 +1637,9 @@ mod postgres_tests { }) .await .expect("connect application writer"); - let result = db.insert_event(community, event, channel).await; + let result = db + .insert_event(community, event, channel, crate::ArchiveGate::Exempt) + .await; db.push_enqueue .as_ref() .expect("enabled producer") @@ -2877,7 +2879,7 @@ mod postgres_tests { let raced = nostr::EventBuilder::new(nostr::Kind::Custom(9), "raced enqueue") .sign_with_keys(&nostr::Keys::generate()) .expect("sign raced event"); - crate::event::insert_event(&pool, community, &raced, None) + crate::event::insert_event(&pool, community, &raced, None, crate::ArchiveGate::Exempt) .await .expect("store event before post-commit enqueue"); let (held_tx, held_rx) = tokio::sync::oneshot::channel(); diff --git a/crates/buzz-db/src/store/reaction.rs b/crates/buzz-db/src/store/reaction.rs index 8c636e1efeb..efb506196a4 100644 --- a/crates/buzz-db/src/store/reaction.rs +++ b/crates/buzz-db/src/store/reaction.rs @@ -8,12 +8,12 @@ use nostr::Event; use sqlx::{PgPool, Row}; use uuid::Uuid; -use crate::AdmittedTx; use crate::{ error::Result, event::{insert_event_with_thread_metadata_tx, ThreadMetadataParams}, Db, }; +use crate::{AdmittedTx, ArchiveGate}; use buzz_core::{CommunityId, StoredEvent}; // -- Public structs ----------------------------------------------------------- @@ -103,6 +103,10 @@ const ADD_REACTION_SQL: &str = r#" /// /// Uses `INSERT ... ON CONFLICT DO UPDATE` to eliminate the TOCTOU race where /// two concurrent adds both see no existing row and then race to INSERT. +/// +/// Test-only: it takes no write gate. Production stores a reaction with +/// [`insert_reaction_event_with_thread_metadata`]. +#[cfg(test)] pub async fn add_reaction( pool: &PgPool, community: CommunityId, @@ -171,6 +175,9 @@ pub(crate) async fn add_reaction_tx( /// Ordering is load-bearing: resolve target, upsert/reactivate the reaction row, /// check `rows_affected`, then insert the kind:7 event. Active duplicates return /// before event insertion so duplicate reactions never store a duplicate kind:7. +/// +/// `gate` decides whether an archived `channel_id` refuses the write (see +/// [`ArchiveGate`]); the gate is the first lock. #[allow(clippy::too_many_arguments)] pub async fn insert_reaction_event_with_thread_metadata( pool: &PgPool, @@ -181,6 +188,7 @@ pub async fn insert_reaction_event_with_thread_metadata( target_event_id: &[u8], actor_pubkey: &[u8], emoji: &str, + gate: ArchiveGate, ) -> Result { let mut tx = crate::begin_community_event_write_transaction( pool, @@ -188,6 +196,7 @@ pub async fn insert_reaction_event_with_thread_metadata( crate::observability::WriterOperation::EventWrite, ) .await?; + crate::lifecycle::lock_write_gates(&mut tx, gate, channel_id.as_slice()).await?; let target_row = sqlx::query( "SELECT created_at FROM events \ @@ -256,6 +265,20 @@ pub async fn remove_reaction( crate::observability::WriterOperation::EventWrite, ) .await?; + let removed = remove_reaction_tx(&mut tx, event_id, event_created_at, pubkey, emoji).await?; + tx.commit().await?; + Ok(removed) +} + +/// [`remove_reaction`] inside a caller-owned transaction. +pub(crate) async fn remove_reaction_tx( + tx: &mut AdmittedTx, + event_id: &[u8], + event_created_at: DateTime, + pubkey: &[u8], + emoji: &str, +) -> Result { + let community = tx.community(); let result = sqlx::query( r#" UPDATE reactions @@ -275,9 +298,6 @@ pub async fn remove_reaction( .bind(emoji) .execute(tx.conn()) .await?; - - tx.commit().await?; - Ok(result.rows_affected() > 0) } @@ -295,6 +315,17 @@ pub async fn remove_reaction_by_source_event_id( crate::observability::WriterOperation::EventWrite, ) .await?; + let removed = remove_reaction_by_source_event_id_tx(&mut tx, reaction_event_id).await?; + tx.commit().await?; + Ok(removed) +} + +/// [`remove_reaction_by_source_event_id`] inside a caller-owned transaction. +pub(crate) async fn remove_reaction_by_source_event_id_tx( + tx: &mut AdmittedTx, + reaction_event_id: &[u8], +) -> Result { + let community = tx.community(); let result = sqlx::query( r#" UPDATE reactions @@ -308,9 +339,6 @@ pub async fn remove_reaction_by_source_event_id( .bind(reaction_event_id) .execute(tx.conn()) .await?; - - tx.commit().await?; - Ok(result.rows_affected() > 0) } @@ -361,6 +389,8 @@ pub async fn get_active_reaction_record( /// /// Called after the kind:7 event is created and stored, to link the /// reaction row to its source event. Returns `true` if the row was updated. +/// Test-only: it takes no write gate. +#[cfg(test)] pub async fn set_reaction_event_id( pool: &PgPool, community: CommunityId, @@ -579,6 +609,7 @@ impl Db { target_event_id: &[u8], actor_pubkey: &[u8], emoji: &str, + gate: ArchiveGate, ) -> Result { crate::reaction::insert_reaction_event_with_thread_metadata( &self.pool, @@ -589,12 +620,13 @@ impl Db { target_event_id, actor_pubkey, emoji, + gate, ) .await } - /// Add (or re-activate) a reaction. - #[datastore_span(name = "add_reaction", system = "postgresql")] + /// Add (or re-activate) a reaction. Test-only (see [`add_reaction`]). + #[cfg(test)] pub async fn add_reaction( &self, community: CommunityId, @@ -616,7 +648,9 @@ impl Db { .await } - /// Soft-delete a reaction. + /// Soft-delete a reaction. Test-only: production removes a reaction + /// with its kind:5 in [`Db::insert_event_deletion`]. + #[cfg(test)] #[datastore_span(name = "remove_reaction", system = "postgresql")] pub async fn remove_reaction( &self, @@ -637,7 +671,9 @@ impl Db { .await } - /// Soft-delete a reaction by its source event ID. + /// Soft-delete a reaction by its source event ID. Test-only: production + /// removes a reaction with its kind:5 in [`Db::insert_event_deletion`]. + #[cfg(test)] #[datastore_span(name = "remove_reaction_by_source_event_id", system = "postgresql")] pub async fn remove_reaction_by_source_event_id( &self, @@ -673,8 +709,8 @@ impl Db { .await } - /// Backfill the source event ID on an active reaction row. - #[datastore_span(name = "set_reaction_event_id", system = "postgresql")] + /// Backfill the source event ID on an active reaction row. Test-only. + #[cfg(test)] pub async fn set_reaction_event_id( &self, community: CommunityId, @@ -835,7 +871,7 @@ mod postgres_tests { let pool = setup_pool().await; let community = CommunityId::from_uuid(make_test_community(&pool).await); let target = make_text_event("long custom emoji target"); - insert_event(&pool, community, &target, None) + insert_event(&pool, community, &target, None, crate::ArchiveGate::Exempt) .await .expect("insert target"); @@ -851,6 +887,7 @@ mod postgres_tests { target.id.as_bytes(), &actor.public_key().to_bytes(), &emoji, + crate::ArchiveGate::Exempt, ) .await .expect("store wrapped 64-character shortcode"); @@ -871,7 +908,7 @@ mod postgres_tests { let pool = setup_pool().await; let community = CommunityId::from_uuid(make_test_community(&pool).await); let target = make_text_event("reaction target"); - insert_event(&pool, community, &target, None) + insert_event(&pool, community, &target, None, crate::ArchiveGate::Exempt) .await .expect("insert target"); @@ -890,6 +927,7 @@ mod postgres_tests { target.id.as_bytes(), &actor_pubkey, "👍", + crate::ArchiveGate::Exempt, ) .await .expect("first reaction insert"); @@ -910,6 +948,7 @@ mod postgres_tests { target.id.as_bytes(), &actor_pubkey, "👍", + crate::ArchiveGate::Exempt, ) .await .expect("duplicate reaction insert"); @@ -931,9 +970,15 @@ mod postgres_tests { let community_a = CommunityId::from_uuid(make_test_community(&pool).await); let community_b = CommunityId::from_uuid(make_test_community(&pool).await); let target = make_text_event("community A target only"); - insert_event(&pool, community_a, &target, None) - .await - .expect("insert target in A"); + insert_event( + &pool, + community_a, + &target, + None, + crate::ArchiveGate::Exempt, + ) + .await + .expect("insert target in A"); let actor = Keys::generate(); let actor_pubkey = actor.public_key().to_bytes(); @@ -948,6 +993,7 @@ mod postgres_tests { target.id.as_bytes(), &actor_pubkey, "👍", + crate::ArchiveGate::Exempt, ) .await .expect("cross-community reaction attempt"); @@ -982,7 +1028,7 @@ mod postgres_tests { let pool = setup_pool().await; let community = CommunityId::from_uuid(make_test_community(&pool).await); let target = make_text_event("rollback target"); - insert_event(&pool, community, &target, None) + insert_event(&pool, community, &target, None, crate::ArchiveGate::Exempt) .await .expect("insert target"); @@ -1007,6 +1053,7 @@ mod postgres_tests { target.id.as_bytes(), &actor_pubkey, "👍", + crate::ArchiveGate::Exempt, ) .await .expect_err("ephemeral event insert must fail after reaction upsert attempt"); @@ -1034,7 +1081,7 @@ mod postgres_tests { let pool = setup_pool().await; let community = CommunityId::from_uuid(make_test_community(&pool).await); let target = make_text_event("reactivation target"); - insert_event(&pool, community, &target, None) + insert_event(&pool, community, &target, None, crate::ArchiveGate::Exempt) .await .expect("insert target"); @@ -1056,6 +1103,7 @@ mod postgres_tests { target.id.as_bytes(), &actor_pubkey, "👍", + crate::ArchiveGate::Exempt ) .await .expect("first reaction insert"), @@ -1081,6 +1129,7 @@ mod postgres_tests { target.id.as_bytes(), &actor_pubkey, "👍", + crate::ArchiveGate::Exempt, ) .await .expect("reactivate reaction"); @@ -1126,9 +1175,15 @@ mod postgres_tests { let channel = make_test_channel(&scratch_pool, community_uuid).await; let target = make_text_event("reaction mention failure target"); - insert_event(&scratch_pool, community, &target, Some(channel)) - .await - .expect("insert reaction target"); + insert_event( + &scratch_pool, + community, + &target, + Some(channel), + crate::ArchiveGate::Exempt, + ) + .await + .expect("insert reaction target"); let actor = Keys::generate(); let mentioned = Keys::generate(); @@ -1160,6 +1215,7 @@ mod postgres_tests { target.id.as_bytes(), &actor.public_key().to_bytes(), "👍", + crate::ArchiveGate::Exempt, ) .await .expect_err("mention-indexing failure must fail reaction insert"); @@ -1217,9 +1273,15 @@ mod postgres_tests { let channel = make_test_channel(&pool, community_uuid).await; let target = make_text_event("reaction mention success target"); - insert_event(&pool, community, &target, Some(channel)) - .await - .expect("insert reaction target"); + insert_event( + &pool, + community, + &target, + Some(channel), + crate::ArchiveGate::Exempt, + ) + .await + .expect("insert reaction target"); let actor = Keys::generate(); let actor_pubkey = actor.public_key().to_bytes(); @@ -1254,6 +1316,7 @@ mod postgres_tests { target.id.as_bytes(), &actor_pubkey, "👍", + crate::ArchiveGate::Exempt, ) .await .expect("insert reaction with thread metadata and mention"); @@ -1458,7 +1521,7 @@ mod postgres_tests { let community = CommunityId::from_uuid(community_uuid); let target = make_text_event("fenced reaction target"); - insert_event(&pool, community, &target, None) + insert_event(&pool, community, &target, None, crate::ArchiveGate::Exempt) .await .expect("insert target before fencing"); @@ -1545,6 +1608,7 @@ mod postgres_tests { target.id.as_bytes(), &actor_pubkey, emoji, + crate::ArchiveGate::Exempt, ) .await .expect_err("fenced community must reject reaction inserts"); diff --git a/crates/buzz-db/src/store/read_state.rs b/crates/buzz-db/src/store/read_state.rs index 5a8a2a2c6c1..1c400266acb 100644 --- a/crates/buzz-db/src/store/read_state.rs +++ b/crates/buzz-db/src/store/read_state.rs @@ -152,7 +152,7 @@ mod postgres_tests { }; let before = make(1789090000, "before"); let after = make(1789090001, "after"); - db.replace_parameterized_event(community, &before, &slot, None) + db.replace_parameterized_event(community, &before, &slot, None, crate::ArchiveGate::Exempt) .await .unwrap(); @@ -208,10 +208,16 @@ mod postgres_tests { .await .expect("snapshot must reach the real metadata read"); assert!( - db.replace_parameterized_event(community, &after, &slot, None) - .await - .unwrap() - .1 + db.replace_parameterized_event( + community, + &after, + &slot, + None, + crate::ArchiveGate::Exempt + ) + .await + .unwrap() + .1 ); sqlx::query("SELECT pg_advisory_unlock(81193042)") .execute(&mut *blocker) @@ -249,7 +255,9 @@ mod postgres_tests { .tags([Tag::identifier("escape-fixture")]) .sign_with_keys(&key) .unwrap(); - db.insert_event(community, &event, None).await.unwrap(); + db.insert_event(community, &event, None, crate::ArchiveGate::Exempt) + .await + .unwrap(); let dead_replica = PgPoolOptions::new() .connect_lazy("postgres://unused@127.0.0.1:1/absent") .unwrap(); diff --git a/crates/buzz-db/src/store/relay_admin_actions.rs b/crates/buzz-db/src/store/relay_admin_actions.rs index f1fa4af9beb..a2bd0724a2e 100644 --- a/crates/buzz-db/src/store/relay_admin_actions.rs +++ b/crates/buzz-db/src/store/relay_admin_actions.rs @@ -3300,6 +3300,7 @@ mod postgres_tests { depth, broadcast: false, }), + crate::ArchiveGate::Exempt, ) .await .expect("thread event"); diff --git a/crates/buzz-db/src/store/reminder.rs b/crates/buzz-db/src/store/reminder.rs index 2078295ac97..6dc051fa213 100644 --- a/crates/buzz-db/src/store/reminder.rs +++ b/crates/buzz-db/src/store/reminder.rs @@ -321,12 +321,24 @@ mod postgres_tests { .sign_with_keys(&keys_b) .expect("sign B"); - insert_event(&pool, community_a, &event_a, None) - .await - .expect("insert A"); - insert_event(&pool, community_b, &event_b, None) - .await - .expect("insert B"); + insert_event( + &pool, + community_a, + &event_a, + None, + crate::ArchiveGate::Exempt, + ) + .await + .expect("insert A"); + insert_event( + &pool, + community_b, + &event_b, + None, + crate::ArchiveGate::Exempt, + ) + .await + .expect("insert B"); let due = query_due_reminders(&pool, Utc::now().timestamp(), 100) .await @@ -358,7 +370,7 @@ mod postgres_tests { ]) .sign_with_keys(&keys) .expect("sign reminder"); - insert_event(&pool, community, &event, None) + insert_event(&pool, community, &event, None, crate::ArchiveGate::Exempt) .await .expect("insert reminder"); @@ -400,7 +412,7 @@ mod postgres_tests { ]) .sign_with_keys(&keys) .expect("sign reminder"); - insert_event(&pool, community, &event, None) + insert_event(&pool, community, &event, None, crate::ArchiveGate::Exempt) .await .expect("insert reminder"); @@ -472,10 +484,10 @@ mod postgres_tests { ]) .sign_with_keys(&keys) .expect("sign reminder"); - insert_event(&pool, community_a, &event, None) + insert_event(&pool, community_a, &event, None, crate::ArchiveGate::Exempt) .await .expect("insert A/X"); - insert_event(&pool, community_b, &event, None) + insert_event(&pool, community_b, &event, None, crate::ArchiveGate::Exempt) .await .expect("insert B/X"); diff --git a/crates/buzz-db/src/store/replaceable.rs b/crates/buzz-db/src/store/replaceable.rs index 7c1789c3c49..81ac50d4747 100644 --- a/crates/buzz-db/src/store/replaceable.rs +++ b/crates/buzz-db/src/store/replaceable.rs @@ -3,10 +3,11 @@ use buzz_core::{CommunityId, StoredEvent}; use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; +use std::collections::BTreeSet; use uuid::Uuid; use crate::observability::{self, LockType, TransactionOperation}; -use crate::AdmittedTx; +use crate::{AdmittedTx, ArchiveGate, WriteGates}; use crate::{Db, DbError, Result}; /// Result category for a parameterized-replaceable event write. @@ -96,18 +97,102 @@ pub(crate) fn event_replacement_lock_key( hash as i64 } +/// The channels of the live versions of the coordinate `(kind, pubkey, +/// d_tag)`, optionally only those created at or before `created_at_or_before`. +/// This read takes no lock: a caller gates the result, then re-checks it under +/// the coordinate lock. +pub(crate) async fn live_version_channels( + tx: &mut AdmittedTx, + kind: i32, + pubkey: &[u8], + d_tag: &str, + created_at_or_before: Option>, +) -> Result> { + let channels: Vec = sqlx::query_scalar( + "SELECT DISTINCT channel_id FROM events \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 \ + AND deleted_at IS NULL AND channel_id IS NOT NULL \ + AND ($5::timestamptz IS NULL OR created_at <= $5)", + ) + .bind(tx.community().as_uuid()) + .bind(kind) + .bind(pubkey) + .bind(d_tag) + .bind(created_at_or_before) + .fetch_all(tx.conn()) + .await?; + Ok(channels.into_iter().collect()) +} + +/// Take the write gates a parameterized replacement of `event` needs: its +/// own `channels` (its channel, and any other channel the write must gate) +/// and the channel of every live version it would supersede. +/// +/// A replacement supersedes the coordinate in every channel, so an +/// [`ArchiveGate::Refuse`] write is refused when any of those channels is +/// archived. The versions are read before the gates; the replacement checks +/// them again under its coordinate lock and fails with +/// [`DbError::LifecycleConflict`] if one moved to an ungated channel. +pub async fn lock_parameterized_write_gates( + tx: &mut AdmittedTx, + gate: ArchiveGate, + event: &nostr::Event, + d_tag: &str, + channels: &[Uuid], +) -> Result { + lock_coordinate_write_gates( + tx, + gate, + buzz_core::kind::event_kind_i32(event), + event.pubkey.to_bytes().as_slice(), + d_tag, + None, + channels, + ) + .await +} + +/// Take the write gates of a write to the coordinate `(kind, pubkey, d_tag)`: +/// its own `channels` and the channel of every live version, optionally only +/// those created at or before `created_at_or_before`. This is the one +/// definition of the channels a coordinate write touches. Each caller +/// re-checks its own versions under the coordinate lock. +pub(crate) async fn lock_coordinate_write_gates( + tx: &mut AdmittedTx, + gate: ArchiveGate, + kind: i32, + pubkey: &[u8], + d_tag: &str, + created_at_or_before: Option>, + channels: &[Uuid], +) -> Result { + if gate == ArchiveGate::Exempt { + return Ok(WriteGates::exempt()); + } + let mut gated = live_version_channels(tx, kind, pubkey, d_tag, created_at_or_before).await?; + gated.extend(channels); + let gated: Vec = gated.into_iter().collect(); + crate::lifecycle::lock_write_gates(tx, gate, &gated).await +} + /// Replace a parameterized event in a caller-owned transaction. /// /// This function acquires a transaction-scoped advisory lock but never commits /// or rolls back the outer transaction. The typed precondition can require the /// current live head to have an exact event ID or restrict the operation to an /// idempotent replay. +/// +/// `gates` are the write gates the caller took (see +/// [`lock_parameterized_write_gates`]). A replacement that would write or +/// supersede a version in a channel they do not cover fails with +/// [`DbError::LifecycleConflict`] and writes nothing. async fn replace_parameterized_event_in_transaction_impl( tx: &mut AdmittedTx, event: &nostr::Event, d_tag: &str, channel_id: Option, precondition: ParameterizedReplacePrecondition<'_>, + gates: &WriteGates, ) -> Result { let community_id = tx.community(); let kind_i32 = buzz_core::kind::event_kind_i32(event); @@ -251,6 +336,20 @@ async fn replace_parameterized_event_in_transaction_impl( )); } + // Under the coordinate lock no version can appear in another channel. + // Every channel this write touches must hold its gate. + if !gates.is_exempt() { + let mut touched = if existing.is_some() { + live_version_channels(tx, kind_i32, pubkey_bytes.as_slice(), d_tag, None).await? + } else { + BTreeSet::new() + }; + touched.extend(channel_id); + if !touched.into_iter().all(|channel| gates.covers(channel)) { + return Err(DbError::LifecycleConflict); + } + } + // Everything from the supersede through the mention index runs under one // savepoint on the admitted transaction. A duplicate insert or any failure // (including mention indexing) rolls the whole replacement back and leaves @@ -404,11 +503,15 @@ impl Db { /// Returns `(event, false)` for stale writes and duplicate IDs — callers should /// skip fan-out/dispatch when `was_inserted` is false. #[datastore_span(name = "replace_addressable_event", system = "postgresql")] + /// + /// `gate` decides whether an archived `channel_id` refuses the write (see + /// [`ArchiveGate`]); the gate comes before the replacement lock. pub async fn replace_addressable_event( &self, community_id: CommunityId, event: &nostr::Event, channel_id: Option, + gate: ArchiveGate, ) -> Result<(StoredEvent, bool)> { let kind_i32 = buzz_core::kind::event_kind_i32(event); let pubkey_bytes = event.pubkey.to_bytes(); @@ -431,6 +534,7 @@ impl Db { ) .await?; tx.set_push_enqueue(self.push_enqueue.clone()); + crate::lifecycle::lock_write_gates(&mut tx, gate, channel_id.as_slice()).await?; let transaction_timer = observability::TransactionTimer::start( observability::TransactionOperation::ReplaceAddressableEvent, ); @@ -580,6 +684,11 @@ impl Db { /// savepoint and the caller's transaction stays usable. This future is not /// cancel-safe: if it is dropped before completing, drop the transaction /// too rather than continuing to write on it. + /// + /// `gates` are the write gates the caller took first, from + /// [`lock_parameterized_write_gates`] (or [`WriteGates::exempt`]). A + /// version in a channel they do not cover fails with + /// [`DbError::LifecycleConflict`]. pub async fn replace_parameterized_event_in_transaction( &self, tx: &mut AdmittedTx, @@ -587,15 +696,29 @@ impl Db { d_tag: &str, channel_id: Option, precondition: ParameterizedReplacePrecondition<'_>, + gates: &WriteGates, ) -> Result { - replace_parameterized_event_in_transaction_impl(tx, event, d_tag, channel_id, precondition) - .await + replace_parameterized_event_in_transaction_impl( + tx, + event, + d_tag, + channel_id, + precondition, + gates, + ) + .await } /// Atomically replace a NIP-33 parameterized replaceable event. /// /// Replacement keys on `(kind, pubkey, d_tag)` across channels. The /// highest timestamp wins; same-second ties use the lowest event ID. + /// + /// `gate` decides whether an archived channel refuses the write (see + /// [`ArchiveGate`]): the event's own channel or the channel of any live + /// version it supersedes. A version that moves to an ungated channel + /// between the gate read and the coordinate lock is retried once, then + /// fails with [`DbError::LifecycleConflict`]. #[datastore_span(name = "replace_parameterized_event", system = "postgresql")] pub async fn replace_parameterized_event( &self, @@ -603,6 +726,27 @@ impl Db { event: &nostr::Event, d_tag: &str, channel_id: Option, + gate: ArchiveGate, + ) -> Result<(StoredEvent, bool)> { + match self + .replace_parameterized_event_once(community_id, event, d_tag, channel_id, gate) + .await + { + Err(DbError::LifecycleConflict) => { + self.replace_parameterized_event_once(community_id, event, d_tag, channel_id, gate) + .await + } + result => result, + } + } + + async fn replace_parameterized_event_once( + &self, + community_id: CommunityId, + event: &nostr::Event, + d_tag: &str, + channel_id: Option, + gate: ArchiveGate, ) -> Result<(StoredEvent, bool)> { let mut tx = crate::begin_community_event_write_transaction_with_legacy_metrics( &self.pool, @@ -615,6 +759,14 @@ impl Db { observability::TransactionTimer::start(TransactionOperation::ReplaceParameterizedEvent); transaction_timer .observe(async { + let gates = lock_parameterized_write_gates( + &mut tx, + gate, + event, + d_tag, + channel_id.as_slice(), + ) + .await?; let result = self .replace_parameterized_event_in_transaction( &mut tx, @@ -622,6 +774,7 @@ impl Db { d_tag, channel_id, ParameterizedReplacePrecondition::Unconditional, + &gates, ) .await?; let was_inserted = result.status == ParameterizedReplaceStatus::Inserted; @@ -733,7 +886,7 @@ mod postgres_tests { .custom_created_at(Timestamp::from(base)) .sign_with_keys(&keys) .expect("sign old"); - db.replace_addressable_event(community, &old, Some(channel)) + db.replace_addressable_event(community, &old, Some(channel), crate::ArchiveGate::Exempt) .await .expect("insert old roster"); @@ -759,7 +912,7 @@ mod postgres_tests { .sign_with_keys(&keys) .expect("sign new"); let error = db - .replace_addressable_event(community, &new, Some(channel)) + .replace_addressable_event(community, &new, Some(channel), crate::ArchiveGate::Exempt) .await .expect_err("mention failure must fail replacement"); assert!(error.to_string().contains("injected mention failure")); @@ -977,16 +1130,28 @@ mod postgres_tests { .expect("sign new"); assert!( - db.replace_parameterized_event(community, &old, &d_tag, None) - .await - .expect("insert old") - .1 + db.replace_parameterized_event( + community, + &old, + &d_tag, + None, + crate::ArchiveGate::Exempt + ) + .await + .expect("insert old") + .1 ); assert!( - db.replace_parameterized_event(community, &new, &d_tag, None) - .await - .expect("replace with new") - .1 + db.replace_parameterized_event( + community, + &new, + &d_tag, + None, + crate::ArchiveGate::Exempt + ) + .await + .expect("replace with new") + .1 ); let rows: i64 = sqlx::query_scalar( @@ -1011,10 +1176,16 @@ mod postgres_tests { .expect("simulate NIP-09 coordinate deletion"); assert!( - !db.replace_parameterized_event(community, &old, &d_tag, None) - .await - .expect("replay old") - .1 + !db.replace_parameterized_event( + community, + &old, + &d_tag, + None, + crate::ArchiveGate::Exempt + ) + .await + .expect("replay old") + .1 ); let live: i64 = sqlx::query_scalar( "SELECT count(*) FROM events WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL", @@ -1111,7 +1282,7 @@ mod postgres_tests { .sign_with_keys(&keys) .expect("sign addressable event"); let addressable_error = db - .replace_addressable_event(community, &addressable, None) + .replace_addressable_event(community, &addressable, None, crate::ArchiveGate::Exempt) .await .expect_err("fenced community must reject addressable replacement"); assert!( @@ -1129,7 +1300,13 @@ mod postgres_tests { .sign_with_keys(&keys) .expect("sign parameterized event"); let parameterized_error = db - .replace_parameterized_event(community, ¶meterized, &d_tag, None) + .replace_parameterized_event( + community, + ¶meterized, + &d_tag, + None, + crate::ArchiveGate::Exempt, + ) .await .expect_err("fenced community must reject parameterized replacement"); assert!( @@ -1167,16 +1344,28 @@ mod postgres_tests { let victim = event(&victim_d_tag, "victim", base); assert!( - db.replace_parameterized_event(community, &old, &replace_d_tag, None) - .await - .expect("insert old head") - .1 + db.replace_parameterized_event( + community, + &old, + &replace_d_tag, + None, + crate::ArchiveGate::Exempt + ) + .await + .expect("insert old head") + .1 ); assert!( - db.replace_parameterized_event(community, &victim, &victim_d_tag, None) - .await - .expect("insert victim head") - .1 + db.replace_parameterized_event( + community, + &victim, + &victim_d_tag, + None, + crate::ArchiveGate::Exempt + ) + .await + .expect("insert victim head") + .1 ); let mut tx = db @@ -1190,6 +1379,7 @@ mod postgres_tests { &replace_d_tag, None, replaceable::ParameterizedReplacePrecondition::Unconditional, + &crate::WriteGates::exempt(), ) .await .expect("replace inside caller transaction"); @@ -1243,10 +1433,16 @@ mod postgres_tests { let new = event("new", base + 1); assert!( - db.replace_parameterized_event(community, &old, &d_tag, None) - .await - .expect("insert old head") - .1 + db.replace_parameterized_event( + community, + &old, + &d_tag, + None, + crate::ArchiveGate::Exempt + ) + .await + .expect("insert old head") + .1 ); let mut tx = db @@ -1262,6 +1458,7 @@ mod postgres_tests { replaceable::ParameterizedReplacePrecondition::ExpectedRevision( old.id.as_bytes().as_slice(), ), + &crate::WriteGates::exempt(), ) .await .expect("replace inside caller transaction"); @@ -1297,6 +1494,7 @@ mod postgres_tests { replaceable::ParameterizedReplacePrecondition::ExpectedRevision( [0x42; 32].as_slice(), ), + &crate::WriteGates::exempt(), ) .await .expect("evaluate stale revision"); @@ -1330,6 +1528,7 @@ mod postgres_tests { replaceable::ParameterizedReplacePrecondition::ExpectedRevision( [0x24; 32].as_slice(), ), + &crate::WriteGates::exempt(), ) .await .expect("evaluate missing revision"); @@ -1371,10 +1570,16 @@ mod postgres_tests { .expect("sign new project"); assert!( - db.replace_parameterized_event(community, &old, &d_tag, None) - .await - .expect("insert old project") - .1 + db.replace_parameterized_event( + community, + &old, + &d_tag, + None, + crate::ArchiveGate::Exempt + ) + .await + .expect("insert old project") + .1 ); sqlx::query( "CREATE FUNCTION reject_test_mention() RETURNS trigger AS $$ \ @@ -1403,6 +1608,7 @@ mod postgres_tests { &d_tag, None, replaceable::ParameterizedReplacePrecondition::Unconditional, + &crate::WriteGates::exempt(), ) .await .expect_err("mention failure must fail replacement"); @@ -1458,10 +1664,16 @@ mod postgres_tests { let duplicate = event("soft-deleted-duplicate", base + 1); assert!( - db.replace_parameterized_event(community, &duplicate, &d_tag, None) - .await - .expect("insert future duplicate") - .1 + db.replace_parameterized_event( + community, + &duplicate, + &d_tag, + None, + crate::ArchiveGate::Exempt + ) + .await + .expect("insert future duplicate") + .1 ); sqlx::query("UPDATE events SET deleted_at=NOW() WHERE community_id=$1 AND id=$2") .bind(community.as_uuid()) @@ -1491,6 +1703,7 @@ mod postgres_tests { &d_tag, None, replaceable::ParameterizedReplacePrecondition::Unconditional, + &crate::WriteGates::exempt(), ) .await .expect("evaluate soft-deleted duplicate"); @@ -1540,8 +1753,20 @@ mod postgres_tests { }; let (first_result, second_result) = tokio::join!( - db.replace_parameterized_event(community, &first, &d_tag, None), - db.replace_parameterized_event(community, &second, &d_tag, None), + db.replace_parameterized_event( + community, + &first, + &d_tag, + None, + crate::ArchiveGate::Exempt + ), + db.replace_parameterized_event( + community, + &second, + &d_tag, + None, + crate::ArchiveGate::Exempt + ), ); let first_inserted = first_result.expect("first concurrent writer").1; let second_inserted = second_result.expect("second concurrent writer").1; @@ -1564,18 +1789,30 @@ mod postgres_tests { assert_eq!(live_ids, vec![expected.id.as_bytes().to_vec()]); assert!( - !db.replace_parameterized_event(community, expected, &d_tag, None) - .await - .expect("replay winning event") - .1, + !db.replace_parameterized_event( + community, + expected, + &d_tag, + None, + crate::ArchiveGate::Exempt + ) + .await + .expect("replay winning event") + .1, "replaying the live event must be idempotent", ); let stale = event("stale", created_at.saturating_sub(1)); assert!( - !db.replace_parameterized_event(community, &stale, &d_tag, None) - .await - .expect("submit stale event") - .1, + !db.replace_parameterized_event( + community, + &stale, + &d_tag, + None, + crate::ArchiveGate::Exempt + ) + .await + .expect("submit stale event") + .1, "an older event must not replace the live head", ); } @@ -1604,10 +1841,16 @@ mod postgres_tests { .sign_with_keys(&keys) .expect("sign mesh status"); assert!( - db.replace_parameterized_event(community, &event, d_tag, None) - .await - .expect("replace mesh status") - .1 + db.replace_parameterized_event( + community, + &event, + d_tag, + None, + crate::ArchiveGate::Exempt + ) + .await + .expect("replace mesh status") + .1 ); } @@ -1705,16 +1948,28 @@ mod postgres_tests { .expect("sign new event"); assert!( - db.replace_parameterized_event(community, &old, &d_tag, None) - .await - .expect("insert old event") - .1 + db.replace_parameterized_event( + community, + &old, + &d_tag, + None, + crate::ArchiveGate::Exempt + ) + .await + .expect("insert old event") + .1 ); assert!( - db.replace_parameterized_event(community, &new, &d_tag, None) - .await - .expect("replace with new event") - .1 + db.replace_parameterized_event( + community, + &new, + &d_tag, + None, + crate::ArchiveGate::Exempt + ) + .await + .expect("replace with new event") + .1 ); let (rows, live): (i64, i64) = sqlx::query_as( @@ -1766,10 +2021,16 @@ mod postgres_tests { .sign_with_keys(&keys) .expect("sign conforming event"); assert!( - db.replace_parameterized_event(community, &conforming, &conforming_d, None) - .await - .expect("insert conforming event") - .1 + db.replace_parameterized_event( + community, + &conforming, + &conforming_d, + None, + crate::ArchiveGate::Exempt + ) + .await + .expect("insert conforming event") + .1 ); sqlx::query( "INSERT INTO event_mentions \ @@ -1823,10 +2084,16 @@ mod postgres_tests { .sign_with_keys(&keys) .expect("sign nonconforming event"); assert!( - db.replace_parameterized_event(community, &nonconforming, &nonconforming_d, None,) - .await - .expect("insert nonconforming event") - .1 + db.replace_parameterized_event( + community, + &nonconforming, + &nonconforming_d, + None, + crate::ArchiveGate::Exempt + ) + .await + .expect("insert nonconforming event") + .1 ); let rejected_nonconforming = sqlx::query( "DELETE FROM events WHERE community_id=$1 AND id=$2 AND created_at=to_timestamp($3)", @@ -1848,10 +2115,16 @@ mod postgres_tests { .sign_with_keys(&keys) .expect("sign unrelated event"); assert!( - db.replace_parameterized_event(community, &unrelated, &unrelated_d, None) - .await - .expect("insert unrelated event") - .1 + db.replace_parameterized_event( + community, + &unrelated, + &unrelated_d, + None, + crate::ArchiveGate::Exempt + ) + .await + .expect("insert unrelated event") + .1 ); let unrelated_delete = sqlx::query( "DELETE FROM events WHERE community_id=$1 AND id=$2 AND created_at=to_timestamp($3)", @@ -2074,10 +2347,16 @@ mod postgres_tests { .sign_with_keys(&keys) .expect("sign event"); assert!( - db.replace_parameterized_event(community, &event, &d_tag, None) - .await - .expect("store event") - .1, + db.replace_parameterized_event( + community, + &event, + &d_tag, + None, + crate::ArchiveGate::Exempt + ) + .await + .expect("store event") + .1, "{label}: event must be stored" ); let id = event.id.as_bytes().to_vec(); @@ -2263,10 +2542,16 @@ mod postgres_tests { .sign_with_keys(&keys) .expect("sign old head"); assert!( - db.replace_parameterized_event(community, &old_head, &d_tag, None) - .await - .expect("store old head") - .1 + db.replace_parameterized_event( + community, + &old_head, + &d_tag, + None, + crate::ArchiveGate::Exempt + ) + .await + .expect("store old head") + .1 ); let old_id = old_head.id.as_bytes().to_vec(); let new_id = vec![0x5a_u8; 32]; diff --git a/crates/buzz-db/src/store/thread.rs b/crates/buzz-db/src/store/thread.rs index 7c617aba6a8..0d211c41442 100644 --- a/crates/buzz-db/src/store/thread.rs +++ b/crates/buzz-db/src/store/thread.rs @@ -173,6 +173,10 @@ pub struct ThreadMetadataRecord { /// The INSERT and all counter UPDATEs are wrapped in a single transaction so a /// crash between them cannot leave reply_count / descendant_count inconsistent /// with the actual number of reply rows (F9). +/// +/// Test-only: it takes no write gate. Production writes thread metadata with +/// its event ([`crate::Db::insert_event_with_thread_metadata`]). +#[cfg(test)] #[allow(clippy::too_many_arguments)] pub async fn insert_thread_metadata( pool: &PgPool, @@ -822,9 +826,9 @@ pub async fn get_thread_metadata_by_event( // -- Db API ------------------------------------------------------------------- impl Db { - /// Insert thread metadata. + /// Insert thread metadata. Test-only (see [`insert_thread_metadata`]). + #[cfg(test)] #[allow(clippy::too_many_arguments)] - #[datastore_span(name = "insert_thread_metadata", system = "postgresql")] pub async fn insert_thread_metadata( &self, community_id: CommunityId, @@ -1300,6 +1304,7 @@ mod postgres_tests { depth: i32::from(parent.is_some()), broadcast: false, }), + crate::ArchiveGate::Exempt, ) .await .expect("thread event"); @@ -1391,6 +1396,7 @@ mod postgres_tests { depth: 0, broadcast: true, }), + crate::ArchiveGate::Exempt, ) .await .expect("insert community A metadata"); @@ -1410,6 +1416,7 @@ mod postgres_tests { depth: 3, broadcast: false, }), + crate::ArchiveGate::Exempt, ) .await .expect("insert community B metadata"); @@ -1448,9 +1455,16 @@ mod postgres_tests { let root = make_stream_event(&author, "root"); let root_created_at = event_created_at(&root); - insert_event_with_thread_metadata(&pool, community, &root, Some(channel.id), None) - .await - .expect("insert root event"); + insert_event_with_thread_metadata( + &pool, + community, + &root, + Some(channel.id), + None, + crate::ArchiveGate::Exempt, + ) + .await + .expect("insert root event"); let reply = make_stream_event(&author, "reply"); let reply_created_at = event_created_at(&reply); @@ -1471,6 +1485,7 @@ mod postgres_tests { depth: 1, broadcast: false, }), + crate::ArchiveGate::Exempt, ) .await .expect("insert reply event and metadata"); @@ -1513,9 +1528,16 @@ mod postgres_tests { let root = make_stream_event(&author, "root"); let root_created_at = event_created_at(&root); - insert_event_with_thread_metadata(&pool, community, &root, Some(channel.id), None) - .await - .expect("insert root event"); + insert_event_with_thread_metadata( + &pool, + community, + &root, + Some(channel.id), + None, + crate::ArchiveGate::Exempt, + ) + .await + .expect("insert root event"); // Pin every reply to the SAME second so pagination must lean entirely on // the event_id tiebreak. Distinct content keeps the ids distinct. @@ -1545,6 +1567,7 @@ mod postgres_tests { depth: 1, broadcast: false, }), + crate::ArchiveGate::Exempt, ) .await .expect("insert tied reply"); @@ -1628,9 +1651,16 @@ mod postgres_tests { // Root (no metadata row on first insert — a depth-0 message). let root = make_stream_event(&author, "root"); let root_created_at = event_created_at(&root); - insert_event_with_thread_metadata(&pool, community, &root, Some(channel.id), None) - .await - .expect("insert root event"); + insert_event_with_thread_metadata( + &pool, + community, + &root, + Some(channel.id), + None, + crate::ArchiveGate::Exempt, + ) + .await + .expect("insert root event"); // Depth-1 direct reply to the root (parent == root). let child = make_stream_event(&author, "child"); @@ -1651,6 +1681,7 @@ mod postgres_tests { depth: 1, broadcast: false, }), + crate::ArchiveGate::Exempt, ) .await .expect("insert depth-1 child"); @@ -1675,6 +1706,7 @@ mod postgres_tests { depth: 2, broadcast: false, }), + crate::ArchiveGate::Exempt, ) .await .expect("insert depth-2 grandchild"); @@ -1737,9 +1769,16 @@ mod postgres_tests { let child = make_stream_event(&author, "child"); let grandchild = make_stream_event(&author, "grandchild"); for ev in [&root, &child, &grandchild] { - insert_event_with_thread_metadata(&pool, community, ev, Some(channel.id), None) - .await - .expect("insert event"); + insert_event_with_thread_metadata( + &pool, + community, + ev, + Some(channel.id), + None, + crate::ArchiveGate::Exempt, + ) + .await + .expect("insert event"); } // Depth-2 reply where root_id != parent_id. Before the fix this errored @@ -1795,9 +1834,16 @@ mod postgres_tests { let root = make_stream_event(&author, "root"); let root_created_at = event_created_at(&root); - insert_event_with_thread_metadata(&pool, community, &root, Some(channel.id), None) - .await - .expect("insert root event"); + insert_event_with_thread_metadata( + &pool, + community, + &root, + Some(channel.id), + None, + crate::ArchiveGate::Exempt, + ) + .await + .expect("insert root event"); // Two replies under the same root: one stays valid, one we corrupt. let good = EventBuilder::new(Kind::Custom(9), "good") @@ -1822,6 +1868,7 @@ mod postgres_tests { depth: 1, broadcast: false, }), + crate::ArchiveGate::Exempt, ) .await .expect("insert good reply"); @@ -1847,6 +1894,7 @@ mod postgres_tests { depth: 1, broadcast: false, }), + crate::ArchiveGate::Exempt, ) .await .expect("insert bad reply"); @@ -1893,6 +1941,7 @@ mod postgres_tests { depth: 1, broadcast: false, }), + crate::ArchiveGate::Exempt, ) .await .expect("insert tail"); @@ -1957,9 +2006,16 @@ mod postgres_tests { .await .expect("channel"); let root = make_stream_event(&author, "root"); - insert_event_with_thread_metadata(&pool, community, &root, Some(channel.id), None) - .await - .expect("root"); + insert_event_with_thread_metadata( + &pool, + community, + &root, + Some(channel.id), + None, + crate::ArchiveGate::Exempt, + ) + .await + .expect("root"); let mut corrupt_ids = Vec::new(); for index in 0..65 { let reply = EventBuilder::new(Kind::Custom(9), format!("reply-{index}")) @@ -1984,6 +2040,7 @@ mod postgres_tests { depth: 1, broadcast: false, }), + crate::ArchiveGate::Exempt, ) .await .expect("reply metadata"); @@ -2047,6 +2104,7 @@ mod postgres_tests { depth: 0, broadcast: true, }), + crate::ArchiveGate::Exempt, ) .await .expect("insert top-level event"); @@ -2077,6 +2135,7 @@ mod postgres_tests { depth: 1, broadcast, }), + crate::ArchiveGate::Exempt, ) .await .expect("insert reply event"); @@ -2109,9 +2168,16 @@ mod postgres_tests { // No thread metadata at all — the legacy-ingest shape. Top-level. let bare = make_stream_event(&author, "bare"); - insert_event_with_thread_metadata(&pool, community, &bare, Some(channel.id), None) - .await - .expect("insert bare event"); + insert_event_with_thread_metadata( + &pool, + community, + &bare, + Some(channel.id), + None, + crate::ArchiveGate::Exempt, + ) + .await + .expect("insert bare event"); let broadcast_reply = make_stream_event(&author, "broadcast reply"); insert_reply(&pool, community, channel.id, &root, &broadcast_reply, true).await; diff --git a/crates/buzz-db/src/store/thread_window/postgres_tests.rs b/crates/buzz-db/src/store/thread_window/postgres_tests.rs index a2bda65123a..ec1680fdeee 100644 --- a/crates/buzz-db/src/store/thread_window/postgres_tests.rs +++ b/crates/buzz-db/src/store/thread_window/postgres_tests.rs @@ -30,7 +30,7 @@ async fn fixture() -> (Db, CommunityId, Uuid, Keys, nostr::Event) { .await .unwrap(); let root = make_event(&keys, channel, 9, "root", None, Timestamp::now().as_secs()); - db.insert_event(community, &root, Some(channel)) + db.insert_event(community, &root, Some(channel), crate::ArchiveGate::Exempt) .await .unwrap(); (db, community, channel, keys, root) @@ -169,7 +169,9 @@ async fn cardinalities() { None, root.created_at.as_secs(), ); - db.insert_event(cid, &root, Some(ch)).await.unwrap(); + db.insert_event(cid, &root, Some(ch), crate::ArchiveGate::Exempt) + .await + .unwrap(); let expected = replies(&db, cid, ch, &keys, &root, count).await; assert_pages(&db, cid, request(ch, &root, 50), &expected).await; let (head, _) = db @@ -244,7 +246,9 @@ async fn thread_window_predicates_corruption_and_legacy() { .await .unwrap(); let dense_root = make_event(&keys, ch, 9, "dense root", None, root.created_at.as_secs()); - db.insert_event(cid, &dense_root, Some(ch)).await.unwrap(); + db.insert_event(cid, &dense_root, Some(ch), crate::ArchiveGate::Exempt) + .await + .unwrap(); let rows = replies(&db, cid, ch, &keys, &dense_root, 651).await; for (class, sql) in [ "UPDATE events SET deleted_at=now() WHERE community_id=$1 AND id=ANY($2)", @@ -416,7 +420,7 @@ async fn thread_aux_byte_budget_stops_consumption_and_survives_retry() { Some(&root), root.created_at.as_secs() + n, ); - db.insert_event(community, &edit, Some(channel)) + db.insert_event(community, &edit, Some(channel), crate::ArchiveGate::Exempt) .await .unwrap(); } @@ -484,6 +488,7 @@ async fn thread_reply_byte_budget_stops_before_collecting_page() { depth: 1, broadcast: false, }), + crate::ArchiveGate::Exempt, ) .await .unwrap(); diff --git a/crates/buzz-db/src/store/verified_profiles.rs b/crates/buzz-db/src/store/verified_profiles.rs index db3d09b9942..6df82549291 100644 --- a/crates/buzz-db/src/store/verified_profiles.rs +++ b/crates/buzz-db/src/store/verified_profiles.rs @@ -180,6 +180,8 @@ impl Db { input.pubkey, None, crate::replaceable::ParameterizedReplacePrecondition::Unconditional, + // A relay-signed profile outside every channel. + &crate::WriteGates::exempt(), ) .await?; if !matches!( diff --git a/crates/buzz-db/src/store/workflow.rs b/crates/buzz-db/src/store/workflow.rs index 6f7f64aa2da..cc3c78a788e 100644 --- a/crates/buzz-db/src/store/workflow.rs +++ b/crates/buzz-db/src/store/workflow.rs @@ -21,7 +21,7 @@ use uuid::Uuid; use buzz_core::CommunityId; use crate::error::{DbError, Result}; -use crate::Db; +use crate::{AdmittedTx, ArchiveGate, Db}; use buzz_datastore_tracing::datastore_span; // -- Token hashing ------------------------------------------------------------ @@ -514,12 +514,14 @@ pub async fn list_all_enabled_workflows(pool: &PgPool) -> Result, + gate: ArchiveGate, ) -> Result> { let mut tx = crate::begin_community_write_transaction( pool, @@ -527,6 +529,7 @@ pub async fn claim_scheduled_workflow_fire( crate::observability::WriterOperation::EventWrite, ) .await?; + lock_workflow_channel(&mut tx, workflow_id, gate).await?; let row = sqlx::query( r#" INSERT INTO scheduled_workflow_fires (community_id, workflow_id, scheduled_for) @@ -851,17 +854,87 @@ pub async fn disable_workflows_for_owner_in_channel_on_conn( // -- Workflow Run CRUD -------------------------------------------------------- +/// The refusal, as [`DbError::AccessDenied`], of a gated run or schedule claim +/// whose workflow does not exist or has no channel. +pub const WORKFLOW_UNBOUND: &str = "workflow has no channel"; + +/// Admit a run or a schedule claim of `workflow_id` on its channel's write +/// gate. Call it first in the write's transaction. [`ArchiveGate::Exempt`] +/// takes nothing. Otherwise it reads the channel without a lock, takes that +/// gate, and holds the binding with [`verify_workflow_channel`]. No workflow or +/// no channel fails closed with `AccessDenied(WORKFLOW_UNBOUND)`. +/// +/// Not gated, because they account for work already admitted: +/// `attach_scheduled_workflow_run`, `update_workflow_run`, `finalize_run` and +/// the foreign key cascades. A run's channel posts are gated by the workflow +/// sink, and an approval command gates its workflow's channel. +async fn lock_workflow_channel( + tx: &mut AdmittedTx, + workflow_id: Uuid, + gate: ArchiveGate, +) -> Result<()> { + if gate == ArchiveGate::Exempt { + return Ok(()); + } + let channel = workflow_channel(tx, workflow_id, false) + .await? + .ok_or_else(|| DbError::AccessDenied(WORKFLOW_UNBOUND.into()))?; + crate::lifecycle::lock_write_gates(tx, gate, &[channel]).await?; + verify_workflow_channel(tx, workflow_id, channel).await +} + +/// Hold `workflow_id`'s channel binding under FOR KEY SHARE until commit, and +/// check that it is still `expected`, the channel whose gate the caller holds. +/// +/// A workflow's channel never changes in place: a rebind deletes the row and +/// creates it again with the same id, and the lock blocks that delete. Any +/// other result is [`DbError::LifecycleConflict`]. That includes no row, which +/// READ COMMITTED also returns when a rebind commits while this waits. +pub async fn verify_workflow_channel( + tx: &mut AdmittedTx, + workflow_id: Uuid, + expected: Uuid, +) -> Result<()> { + match workflow_channel(tx, workflow_id, true).await? { + Some(channel) if channel == expected => Ok(()), + _ => Err(DbError::LifecycleConflict), + } +} + +/// The channel of `workflow_id` in the transaction's community, read under +/// FOR KEY SHARE if `key_share`. +async fn workflow_channel( + tx: &mut AdmittedTx, + workflow_id: Uuid, + key_share: bool, +) -> Result> { + let sql = if key_share { + "SELECT channel_id FROM workflows WHERE community_id = $1 AND id = $2 FOR KEY SHARE" + } else { + "SELECT channel_id FROM workflows WHERE community_id = $1 AND id = $2" + }; + let community = tx.community(); + let channel: Option> = sqlx::query_scalar(sql) + .bind(community.as_uuid()) + .bind(workflow_id) + .fetch_optional(tx.conn()) + .await?; + Ok(channel.flatten()) +} + /// Insert a new workflow run. Returns the new run's UUID. /// /// `trigger_context` is the serialized `TriggerContext` for this run. It is stored /// so that post-approval resume steps can restore the original trigger data and -/// correctly resolve `{{trigger.*}}` template variables. +/// correctly resolve `{{trigger.*}}` template variables. The run is admitted on +/// its workflow's channel under `gate` (see `lock_workflow_channel`). pub async fn create_workflow_run( pool: &PgPool, community_id: CommunityId, workflow_id: Uuid, trigger_event_id: Option<&[u8]>, trigger_context: Option<&serde_json::Value>, + gate: ArchiveGate, ) -> Result { let mut tx = crate::begin_community_write_transaction( pool, @@ -869,6 +942,7 @@ pub async fn create_workflow_run( crate::observability::WriterOperation::EventWrite, ) .await?; + lock_workflow_channel(&mut tx, workflow_id, gate).await?; let id = Uuid::new_v4(); sqlx::query( @@ -1363,6 +1437,7 @@ impl Db { workflow_id: Uuid, trigger_event_id: Option<&[u8]>, trigger_context: Option<&serde_json::Value>, + gate: ArchiveGate, ) -> Result { crate::workflow::create_workflow_run( &self.pool, @@ -1370,6 +1445,7 @@ impl Db { workflow_id, trigger_event_id, trigger_context, + gate, ) .await } @@ -1627,12 +1703,14 @@ impl Db { community_id: CommunityId, workflow_id: Uuid, scheduled_for: chrono::DateTime, + gate: ArchiveGate, ) -> Result> { crate::workflow::claim_scheduled_workflow_fire( &self.pool, community_id, workflow_id, scheduled_for, + gate, ) .await } @@ -2353,10 +2431,16 @@ mod postgres_tests { let scheduled_for = Utc.with_ymd_and_hms(2026, 6, 27, 0, 0, 0).unwrap(); // Claim A/id/t. - let claim_a = claim_scheduled_workflow_fire(&pool, community_a, workflow_id, scheduled_for) - .await - .expect("claim A should not error") - .expect("claim A should win"); + let claim_a = claim_scheduled_workflow_fire( + &pool, + community_a, + workflow_id, + scheduled_for, + ArchiveGate::Exempt, + ) + .await + .expect("claim A should not error") + .expect("claim A should win"); assert_eq!( claim_a.community_id, community_a, "A-claim must resolve to community A (server provenance)" @@ -2365,10 +2449,16 @@ mod postgres_tests { assert_eq!(claim_a.scheduled_for, scheduled_for); // B/id/t must still be claimable — A's claim did not touch B's row. - let claim_b = claim_scheduled_workflow_fire(&pool, community_b, workflow_id, scheduled_for) - .await - .expect("claim B should not error") - .expect("claim B must still win — A's claim must not have consumed B's instant"); + let claim_b = claim_scheduled_workflow_fire( + &pool, + community_b, + workflow_id, + scheduled_for, + ArchiveGate::Exempt, + ) + .await + .expect("claim B should not error") + .expect("claim B must still win — A's claim must not have consumed B's instant"); assert_eq!( claim_b.community_id, community_b, "B-claim must resolve to community B" @@ -2376,10 +2466,15 @@ mod postgres_tests { // And a second A-claim for the same instant must now lose (dedup holds // within the community). - let claim_a_again = - claim_scheduled_workflow_fire(&pool, community_a, workflow_id, scheduled_for) - .await - .expect("second A-claim should not error"); + let claim_a_again = claim_scheduled_workflow_fire( + &pool, + community_a, + workflow_id, + scheduled_for, + ArchiveGate::Exempt, + ) + .await + .expect("second A-claim should not error"); assert!( claim_a_again.is_none(), "the same (A, id, t) instant must not be claimable twice" @@ -2405,7 +2500,14 @@ mod postgres_tests { for _ in 0..N { let pool = pool.clone(); handles.push(tokio::spawn(async move { - claim_scheduled_workflow_fire(&pool, community, workflow_id, scheduled_for).await + claim_scheduled_workflow_fire( + &pool, + community, + workflow_id, + scheduled_for, + ArchiveGate::Exempt, + ) + .await })); } @@ -2440,15 +2542,28 @@ mod postgres_tests { let scheduled_for = Utc.with_ymd_and_hms(2026, 6, 27, 0, 2, 0).unwrap(); // Win the claim for this instant. - claim_scheduled_workflow_fire(&pool, community, workflow_id, scheduled_for) - .await - .expect("claim ok") - .expect("claim wins"); + claim_scheduled_workflow_fire( + &pool, + community, + workflow_id, + scheduled_for, + ArchiveGate::Exempt, + ) + .await + .expect("claim ok") + .expect("claim wins"); // Create the run the won claim is responsible for, then attach it. - let run_id = create_workflow_run(&pool, community, workflow_id, None, None) - .await - .expect("create run ok"); + let run_id = create_workflow_run( + &pool, + community, + workflow_id, + None, + None, + ArchiveGate::Exempt, + ) + .await + .expect("create run ok"); let attached = attach_scheduled_workflow_run(&pool, community, workflow_id, scheduled_for, run_id) @@ -2475,9 +2590,16 @@ mod postgres_tests { // A second attach is a no-op: the `workflow_run_id IS NULL` guard means // an already-linked claim is never re-pointed to a different run. - let other_run = create_workflow_run(&pool, community, workflow_id, None, None) - .await - .expect("create second run ok"); + let other_run = create_workflow_run( + &pool, + community, + workflow_id, + None, + None, + ArchiveGate::Exempt, + ) + .await + .expect("create second run ok"); let reattached = attach_scheduled_workflow_run(&pool, community, workflow_id, scheduled_for, other_run) .await @@ -2511,10 +2633,16 @@ mod postgres_tests { let (workflow_id, _) = make_workflow_in(&pool, community).await; let scheduled_for = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(); - claim_scheduled_workflow_fire(&pool, community, workflow_id, scheduled_for) - .await - .expect("claim ok") - .expect("first claim wins"); + claim_scheduled_workflow_fire( + &pool, + community, + workflow_id, + scheduled_for, + ArchiveGate::Exempt, + ) + .await + .expect("claim ok") + .expect("first claim wins"); // Backdate this row's `claimed_at` so the global prune below targets // only this workflow's row and cannot race-delete other tests' rows. @@ -2762,12 +2890,26 @@ mod postgres_tests { insert_workflow_with_ids(&pool, community_a, workflow_id, channel_id, "wf-A").await; insert_workflow_with_ids(&pool, community_b, workflow_id, Uuid::new_v4(), "wf-B").await; - let run_a = create_workflow_run(&pool, community_a, workflow_id, None, None) - .await - .expect("run A"); - let run_b = create_workflow_run(&pool, community_b, workflow_id, None, None) - .await - .expect("run B"); + let run_a = create_workflow_run( + &pool, + community_a, + workflow_id, + None, + None, + ArchiveGate::Exempt, + ) + .await + .expect("run A"); + let run_b = create_workflow_run( + &pool, + community_b, + workflow_id, + None, + None, + ArchiveGate::Exempt, + ) + .await + .expect("run B"); let token = "shared-approval-token"; let expires = Utc::now() + chrono::Duration::hours(1); diff --git a/crates/buzz-db/src/store/workflow/deletion.rs b/crates/buzz-db/src/store/workflow/deletion.rs index 84f4b828459..47618f81622 100644 --- a/crates/buzz-db/src/store/workflow/deletion.rs +++ b/crates/buzz-db/src/store/workflow/deletion.rs @@ -1,12 +1,11 @@ //! Keep the executable workflow and its client-visible definition in sync. use buzz_core::{kind::KIND_WORKFLOW_DEF, CommunityId, StoredEvent}; -use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; use nostr::Event; use uuid::Uuid; -use crate::AdmittedTx; +use crate::{AdmittedTx, ArchiveGate, WriteGates}; use crate::{Db, DbError, Result}; /// Committed changes made by a workflow-coordinate deletion. @@ -26,7 +25,7 @@ impl Db { /// retry can remove definitions left behind by older relay versions. /// Reports committed changes independently of the optional workflow channel. /// No tombstone is retained: clients may intentionally publish backdated definitions. - #[datastore_span(name = "delete_workflow_by_coordinate", system = "postgresql")] + #[cfg(test)] pub async fn delete_workflow_by_coordinate( &self, community_id: CommunityId, @@ -35,9 +34,14 @@ impl Db { deletion_created_at_secs: i64, ) -> Result { let mut tx = self.begin_event_write_transaction(community_id).await?; - let outcome = - delete_workflow_in_transaction(&mut tx, owner_pubkey, d_tag, deletion_created_at_secs) - .await?; + let outcome = delete_workflow_in_transaction( + &mut tx, + owner_pubkey, + d_tag, + deletion_created_at_secs, + &WriteGates::exempt(), + ) + .await?; tx.commit().await?; Ok(outcome) } @@ -48,14 +52,47 @@ impl Db { /// inserted the request, or it repaired an older relay's incomplete deletion. /// Concurrent identical requests cannot split those two outcomes across commits. /// Authorization of the coordinate is the caller's responsibility. + /// + /// `gate` decides whether the workflow's archived channel refuses the + /// deletion (see [`ArchiveGate`]). The workflow's channel is read first, + /// its gate is taken before the replacement lock, and the channel is read + /// again under the workflow row lock. If it changed, the deletion is + /// retried once and then fails with [`DbError::LifecycleConflict`]. pub async fn insert_workflow_deletion( &self, community_id: CommunityId, event: &Event, owner_pubkey: &[u8], d_tag: &str, + gate: ArchiveGate, + ) -> Result<(StoredEvent, bool, Option)> { + match self + .insert_workflow_deletion_once(community_id, event, owner_pubkey, d_tag, gate) + .await + { + Err(DbError::LifecycleConflict) => { + self.insert_workflow_deletion_once(community_id, event, owner_pubkey, d_tag, gate) + .await + } + result => result, + } + } + + async fn insert_workflow_deletion_once( + &self, + community_id: CommunityId, + event: &Event, + owner_pubkey: &[u8], + d_tag: &str, + gate: ArchiveGate, ) -> Result<(StoredEvent, bool, Option)> { let mut tx = self.begin_event_write_transaction(community_id).await?; + let gates = if gate == ArchiveGate::Exempt { + WriteGates::exempt() + } else { + let channel = workflow_channel(&mut tx, owner_pubkey, d_tag).await?; + crate::lifecycle::lock_write_gates(&mut tx, gate, channel.as_slice()).await? + }; let (stored, inserted) = crate::event::insert_event_in_transaction(&mut tx, event, None).await?; if inserted { @@ -68,6 +105,7 @@ impl Db { owner_pubkey, d_tag, event.created_at.as_secs() as i64, + &gates, ) .await?; tx.commit().await?; @@ -75,11 +113,36 @@ impl Db { } } +/// The SQL that finds the workflow of a coordinate: by its UUID `d` tag, or +/// by the legacy name. `$1` community, `$2` owner, `$3` UUID, `$4` name. +const WORKFLOW_OF_COORDINATE: &str = "community_id = $1 AND owner_pubkey = $2 \ + AND id = COALESCE($3::uuid, (SELECT id FROM workflows \ + WHERE community_id = $1 AND owner_pubkey = $2 AND name = $4 LIMIT 1))"; + +/// The channel of the workflow a coordinate names, read without a lock. +async fn workflow_channel( + tx: &mut AdmittedTx, + owner_pubkey: &[u8], + d_tag: &str, +) -> Result> { + let channel: Option> = sqlx::query_scalar(sqlx::AssertSqlSafe(format!( + "SELECT channel_id FROM workflows WHERE {WORKFLOW_OF_COORDINATE}" + ))) + .bind(tx.community().as_uuid()) + .bind(owner_pubkey) + .bind(Uuid::parse_str(d_tag).ok()) + .bind(d_tag) + .fetch_optional(tx.conn()) + .await?; + Ok(channel.flatten()) +} + async fn delete_workflow_in_transaction( tx: &mut AdmittedTx, owner_pubkey: &[u8], d_tag: &str, deletion_created_at_secs: i64, + gates: &WriteGates, ) -> Result { let community_id = tx.community(); let cutoff = DateTime::from_timestamp(deletion_created_at_secs, 0) @@ -119,19 +182,20 @@ async fn delete_workflow_in_transaction( let workflow_id = Uuid::parse_str(d_tag).ok(); // Lock the workflow first: a concurrent run or fire insert must lock it for // its foreign key, so the child deletes below see every committed child. - let target: Option = sqlx::query_scalar( - "SELECT id FROM workflows WHERE community_id = $1 AND owner_pubkey = $2 \ - AND id = COALESCE($3::uuid, (SELECT id FROM workflows \ - WHERE community_id = $1 AND owner_pubkey = $2 AND name = $4 LIMIT 1)) \ - FOR UPDATE", - ) + let target: Option<(Uuid, Option)> = sqlx::query_as(sqlx::AssertSqlSafe(format!( + "SELECT id, channel_id FROM workflows WHERE {WORKFLOW_OF_COORDINATE} FOR UPDATE" + ))) .bind(community_id.as_uuid()) .bind(owner_pubkey) .bind(workflow_id) .bind(d_tag) .fetch_optional(tx.conn()) .await?; - let deleted_channel = match target { + // The gate was taken for the channel read before the locks. + if target.is_some_and(|(_, channel)| channel.is_some_and(|channel| !gates.covers(channel))) { + return Err(DbError::LifecycleConflict); + } + let deleted_channel = match target.map(|(id, _)| id) { Some(id) => Some(delete_workflow_with_children(tx, id).await?), None => None, }; diff --git a/crates/buzz-db/src/store/workflow/deletion_tests.rs b/crates/buzz-db/src/store/workflow/deletion_tests.rs index 7210d2efb7f..bcaf3c87618 100644 --- a/crates/buzz-db/src/store/workflow/deletion_tests.rs +++ b/crates/buzz-db/src/store/workflow/deletion_tests.rs @@ -48,7 +48,7 @@ async fn seed( .custom_created_at(Timestamp::from(created_at)) .sign_with_keys(keys) .expect("sign definition"); - db.replace_parameterized_event(community, &event, d_tag, None) + db.replace_parameterized_event(community, &event, d_tag, None, crate::ArchiveGate::Exempt) .await .expect("seed definition"); EventQuery { @@ -112,9 +112,16 @@ async fn workflow_deletion_removes_both_projections_and_is_scoped_and_retryable( assert!(!unchanged.changed); assert_present(&db, &query, id).await; - let run_id = create_workflow_run(&db.pool, community, id, None, None) - .await - .expect("run"); + let run_id = create_workflow_run( + &db.pool, + community, + id, + None, + None, + crate::ArchiveGate::Exempt, + ) + .await + .expect("run"); // A fired schedule links both the workflow and a cascading run. Deletion // must also remove the claim despite its NO ACTION run foreign key. sqlx::query( @@ -274,12 +281,30 @@ async fn legacy_stored_deletion_repair_has_one_committed_dispatch_owner() { .sign_with_keys(&keys) .expect("sign legacy deletion"); // Older relay versions could persist the request without applying deletion. - db.insert_event_with_thread_metadata(community, &deletion, None, None) - .await - .expect("legacy request"); + db.insert_event_with_thread_metadata( + community, + &deletion, + None, + None, + crate::ArchiveGate::Exempt, + ) + .await + .expect("legacy request"); let (first, second) = tokio::join!( - db.insert_workflow_deletion(community, &deletion, &owner, &d_tag), - db.insert_workflow_deletion(community, &deletion, &owner, &d_tag), + db.insert_workflow_deletion( + community, + &deletion, + &owner, + &d_tag, + crate::ArchiveGate::Exempt + ), + db.insert_workflow_deletion( + community, + &deletion, + &owner, + &d_tag, + crate::ArchiveGate::Exempt + ), ); let (_, first_dispatch, _) = first.expect("first repair"); let (_, second_dispatch, _) = second.expect("concurrent repair"); @@ -289,10 +314,16 @@ async fn legacy_stored_deletion_repair_has_one_committed_dispatch_owner() { ); assert_absent(&db, &query, id).await; assert!( - !db.insert_workflow_deletion(community, &deletion, &owner, &d_tag) - .await - .expect("completed duplicate") - .1 + !db.insert_workflow_deletion( + community, + &deletion, + &owner, + &d_tag, + crate::ArchiveGate::Exempt + ) + .await + .expect("completed duplicate") + .1 ); } @@ -340,7 +371,13 @@ async fn quiescing_community_rejects_workflow_deletion_at_admission_before_repla .expect_err("a quiescing community must reject coordinate deletion"); let with_request = tokio::time::timeout( bounded, - db.insert_workflow_deletion(community, &deletion, &owner, &d_tag), + db.insert_workflow_deletion( + community, + &deletion, + &owner, + &d_tag, + crate::ArchiveGate::Exempt, + ), ) .await .expect("admission must reject before any deletion write") @@ -391,9 +428,16 @@ async fn workflow_deletion_removes_children_without_cascades() { let query = seed(&db, community, &keys, id, &d_tag, now).await; let other = Uuid::new_v4(); seed(&db, community, &keys, other, &other.to_string(), now).await; - let run_id = create_workflow_run(&db.pool, community, id, None, None) - .await - .expect("run"); + let run_id = create_workflow_run( + &db.pool, + community, + id, + None, + None, + crate::ArchiveGate::Exempt, + ) + .await + .expect("run"); sqlx::query( "INSERT INTO scheduled_workflow_fires \ (community_id, workflow_id, scheduled_for, workflow_run_id) VALUES ($1, $2, NOW(), $3)", @@ -512,9 +556,16 @@ async fn workflow_deletion_waits_for_and_removes_concurrent_cross_workflow_appro seed(&db, community, &keys, id, &d_tag, now).await; let other = Uuid::new_v4(); seed(&db, community, &keys, other, &other.to_string(), now).await; - let run_id = create_workflow_run(&db.pool, community, id, None, None) - .await - .expect("run"); + let run_id = create_workflow_run( + &db.pool, + community, + id, + None, + None, + crate::ArchiveGate::Exempt, + ) + .await + .expect("run"); let mut inserter = db.pool.begin().await.expect("begin approval insert"); sqlx::query( @@ -573,9 +624,16 @@ async fn workflow_deletion_waits_for_concurrent_fire_attach_without_deadlock() { let d_tag = id.to_string(); let now = Timestamp::now().as_secs(); seed(&db, community, &keys, id, &d_tag, now).await; - let run_id = create_workflow_run(&db.pool, community, id, None, None) - .await - .expect("run"); + let run_id = create_workflow_run( + &db.pool, + community, + id, + None, + None, + crate::ArchiveGate::Exempt, + ) + .await + .expect("run"); let scheduled_for: chrono::DateTime = sqlx::query_scalar( "INSERT INTO scheduled_workflow_fires (community_id, workflow_id, scheduled_for) \ VALUES ($1, $2, date_trunc('second', NOW())) RETURNING scheduled_for", diff --git a/crates/buzz-db/tests/observability_source.rs b/crates/buzz-db/tests/observability_source.rs index fcf4e6ddb16..d46de7be32b 100644 --- a/crates/buzz-db/tests/observability_source.rs +++ b/crates/buzz-db/tests/observability_source.rs @@ -490,7 +490,7 @@ fn p0_pool_acquisitions_use_typed_operation_pairs_without_other() { } let channel_reaper = channel - .split_once("pub async fn reap_expired_ephemeral_channels(pool:") + .split_once("pub async fn reap_expired_ephemeral_channels(\n pool:") .expect("channel store must expose ephemeral reaper") .1 .split_once("\nimpl Db {") diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests.rs index 2519510cf4c..5df0ad98dde 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests.rs @@ -162,7 +162,11 @@ async fn project_filter_survives_artifact_query_hook() { // Newer, unbound: `buzz-channel` and the wanted id sit in an unrelated tag. let decoy = event(&["x", "buzz-channel", &home], now); for e in [&bound, &decoy] { - f.state.db.insert_event(f.community, e, None).await.unwrap(); + f.state + .db + .insert_event(f.community, e, None, buzz_db::ArchiveGate::Exempt) + .await + .unwrap(); } let filter = json!([{"kinds":[kind],"#buzz-channel":[f.home],"limit":1}]); let (status, body) = f.request("/query", filter.clone()).await; @@ -192,7 +196,12 @@ async fn count_honors_buzz_channel_filter() { .unwrap(); f.state .db - .insert_event(f.community, &event, Some(f.home)) + .insert_event( + f.community, + &event, + Some(f.home), + buzz_db::ArchiveGate::Exempt, + ) .await .unwrap(); } diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs index b7b559ede57..7768b62cde6 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests/sync_postgres_tests.rs @@ -51,6 +51,7 @@ async fn accept(f: &Fixture, event: &Event) { source, &f.state.relay_keypair, f.state.config.lifecycle_guard_enabled, + buzz_db::ArchiveGate::Exempt ) .await .unwrap(), diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 1d75835ff19..96a7d6ef187 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -2766,11 +2766,29 @@ pub async fn workflow_webhook( .await .map_err(|_| not_found("workflow not found"))?; + // Archived or unbound answers the same 404 as the lookups above. let run_id = state .db - .create_workflow_run(community_id, id, None, trigger_ctx_json.as_ref()) + .create_workflow_run( + community_id, + id, + None, + trigger_ctx_json.as_ref(), + buzz_db::ArchiveGate::Refuse, + ) .await - .map_err(|e| super::internal_error(&format!("db error: {e}")))?; + .map_err(|e| match e { + buzz_db::DbError::AccessDenied(reason) + if reason == buzz_db::lifecycle::CHANNEL_ARCHIVED + || reason == buzz_db::workflow::WORKFLOW_UNBOUND => + { + not_found("workflow not found") + } + buzz_db::DbError::LifecycleConflict => { + api_error(StatusCode::CONFLICT, "conflict: lifecycle changed") + } + e => super::internal_error(&format!("db error: {e}")), + })?; // Spawn workflow execution asynchronously. let engine = Arc::clone(&state.workflow_engine); @@ -6210,8 +6228,13 @@ pub(crate) mod postgres_tests { let event = nostr::EventBuilder::new(nostr::Kind::Metadata, content) .sign_with_keys(&Keys::generate()) .expect("sign profile"); - rt.block_on(state.db.replace_addressable_event(community, &event, None)) - .expect("store profile"); + rt.block_on(state.db.replace_addressable_event( + community, + &event, + None, + buzz_db::ArchiveGate::Exempt, + )) + .expect("store profile"); event }; let named = store(r#"{"name":"wes","display_name":"Wes Anderson"}"#); @@ -8862,6 +8885,7 @@ pub(crate) mod postgres_tests { CommunityId::from_uuid(community), &canvas_ev, Some(channel_id), + buzz_db::ArchiveGate::Exempt, ) .await .expect("insert canvas event on writer"); @@ -9084,7 +9108,7 @@ pub(crate) mod postgres_tests { .await .expect("create channel"); buzz_db::Db::from_pool(pool.clone()) - .insert_event(cid, &root, Some(channel)) + .insert_event(cid, &root, Some(channel), buzz_db::ArchiveGate::Exempt) .await .expect("insert root"); pools.push(pool); diff --git a/crates/buzz-relay/src/api/bridge/read_state_snapshot.rs b/crates/buzz-relay/src/api/bridge/read_state_snapshot.rs index 8ec3fe3ab95..1289c75356d 100644 --- a/crates/buzz-relay/src/api/bridge/read_state_snapshot.rs +++ b/crates/buzz-relay/src/api/bridge/read_state_snapshot.rs @@ -225,7 +225,13 @@ mod postgres_tests { .unwrap(); state .db - .replace_parameterized_event(community, &event, "unrelated-app-without-t-tag", None) + .replace_parameterized_event( + community, + &event, + "unrelated-app-without-t-tag", + None, + buzz_db::ArchiveGate::Exempt, + ) .await .unwrap(); let other_event = EventBuilder::new(Kind::Custom(30078), "other-author") @@ -234,7 +240,13 @@ mod postgres_tests { .unwrap(); state .db - .replace_parameterized_event(community, &other_event, "other", None) + .replace_parameterized_event( + community, + &other_event, + "other", + None, + buzz_db::ArchiveGate::Exempt, + ) .await .unwrap(); state @@ -244,6 +256,7 @@ mod postgres_tests { &event, "unrelated-app-without-t-tag", None, + buzz_db::ArchiveGate::Exempt, ) .await .unwrap(); @@ -313,7 +326,13 @@ mod postgres_tests { .unwrap(); state .db - .replace_parameterized_event(community, &event, "fixture", None) + .replace_parameterized_event( + community, + &event, + "fixture", + None, + buzz_db::ArchiveGate::Exempt, + ) .await .unwrap(); let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) @@ -382,7 +401,13 @@ mod postgres_tests { ); state .db - .replace_parameterized_event(community, &event, "byte-boundary", None) + .replace_parameterized_event( + community, + &event, + "byte-boundary", + None, + buzz_db::ArchiveGate::Exempt, + ) .await .unwrap(); let (status, body) = request(state.clone(), &host, Some(&key), filter(&key)).await; @@ -416,7 +441,7 @@ mod postgres_tests { .unwrap(); state .db - .insert_event(community, &event, None) + .insert_event(community, &event, None, buzz_db::ArchiveGate::Exempt) .await .unwrap(); } diff --git a/crates/buzz-relay/src/api/bridge/thread_window/postgres_tests.rs b/crates/buzz-relay/src/api/bridge/thread_window/postgres_tests.rs index 02ef106cdae..bad1508db17 100644 --- a/crates/buzz-relay/src/api/bridge/thread_window/postgres_tests.rs +++ b/crates/buzz-relay/src/api/bridge/thread_window/postgres_tests.rs @@ -51,7 +51,12 @@ impl Fixture { let root = event(&keys, channel, 9, "root", None, Timestamp::now().as_secs()); state .db - .insert_event(community, &root, Some(channel)) + .insert_event( + community, + &root, + Some(channel), + buzz_db::ArchiveGate::Exempt, + ) .await .unwrap(); let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) @@ -124,6 +129,7 @@ impl Fixture { depth: 1, broadcast: false, }), + buzz_db::ArchiveGate::Exempt, ) .await .unwrap(); @@ -140,7 +146,7 @@ impl Fixture { ); self.state .db - .insert_event(self.community, &aux, channel) + .insert_event(self.community, &aux, channel, buzz_db::ArchiveGate::Exempt) .await .unwrap(); aux @@ -477,7 +483,12 @@ async fn thread_window_scope_roots_and_aggregate_budgets() { ); f.state .db - .insert_event(f.community, &root, Some(channel)) + .insert_event( + f.community, + &root, + Some(channel), + buzz_db::ArchiveGate::Exempt, + ) .await .unwrap(); f.aux(40003, &root, Some(f.channel)).await; @@ -570,7 +581,7 @@ async fn private_deletions_reach_only_their_signer_through_windows_and_id_reads( ] { f.state .db - .insert_event(f.community, event, None) + .insert_event(f.community, event, None, buzz_db::ArchiveGate::Exempt) .await .unwrap(); } diff --git a/crates/buzz-relay/src/api/bridge/thread_window/postgres_tests/failure_postgres_tests.rs b/crates/buzz-relay/src/api/bridge/thread_window/postgres_tests/failure_postgres_tests.rs index d795917cb6e..5383e1e88d3 100644 --- a/crates/buzz-relay/src/api/bridge/thread_window/postgres_tests/failure_postgres_tests.rs +++ b/crates/buzz-relay/src/api/bridge/thread_window/postgres_tests/failure_postgres_tests.rs @@ -342,7 +342,12 @@ async fn thread_window_batch_discards_earlier_windows_after_revocation() { ); f.state .db - .insert_event(f.community, &root, Some(channel)) + .insert_event( + f.community, + &root, + Some(channel), + buzz_db::ArchiveGate::Exempt, + ) .await .unwrap(); f.aux(7, &root, Some(channel)).await; diff --git a/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs index 27920467626..053ad7faa23 100644 --- a/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs +++ b/crates/buzz-relay/src/api/buzz_v1/postgres_tests.rs @@ -195,7 +195,12 @@ async fn accessory_router_signed_url_body_replay_and_actor_boundary() { .unwrap(); state .db - .insert_event(community, &event, Some(channel)) + .insert_event( + community, + &event, + Some(channel), + buzz_db::ArchiveGate::Exempt, + ) .await .unwrap(); state @@ -307,7 +312,12 @@ async fn accessory_batch_outcomes_are_independent_and_return_applied_rows() { .unwrap(); state .db - .insert_event(community, &event, Some(channel)) + .insert_event( + community, + &event, + Some(channel), + buzz_db::ArchiveGate::Exempt, + ) .await .unwrap(); event.id.to_hex() @@ -550,7 +560,12 @@ async fn accessory_write_revocation_is_terminal_before_persistence() { .unwrap(); state .db - .insert_event(community, &event, Some(channel)) + .insert_event( + community, + &event, + Some(channel), + buzz_db::ArchiveGate::Exempt, + ) .await .unwrap(); let intent = buzz_db::personal_read::ReadIntent::MarkThrough { @@ -605,13 +620,22 @@ async fn accessory_discovery_is_host_bound_and_opt_in() { .unwrap(); let host = format!("bff-discovery-{}.local", uuid::Uuid::new_v4()); fixture.db.ensure_configured_community(&host).await.unwrap(); - for (enabled, guard) in [(false, false), (false, true), (true, false), (true, true)] { + // The last row keeps the default subtree limit, which NIP-11 advertises + // as 250. + let default = crate::config::DEFAULT_LIFECYCLE_SUBTREE_CHANNELS; + for (enabled, guard, limit, advertised) in [ + (false, false, 37, 37), + (false, true, 37, 37), + (true, false, 37, 37), + (true, true, 37, 37), + (true, true, default, 250), + ] { let mut state = (*fixture).clone(); let config = Arc::make_mut(&mut state.config); config.buzz_v1_enabled = enabled; config.buzz_v1_retention_seconds = 1234; config.lifecycle_guard_enabled = guard; - config.lifecycle_max_subtree_channels = 37; + config.lifecycle_max_subtree_channels = limit; let state = Arc::new(state); for known_host in [true, false] { let request_host = if known_host { @@ -650,7 +674,7 @@ async fn accessory_discovery_is_host_bound_and_opt_in() { assert_eq!( doc["lifecycle_guard"], json!({"version":1,"state_path":"/buzz/v1/lifecycle/state", - "max_state_channels":1000,"max_subtree_channels":37}) + "max_state_channels":1000,"max_subtree_channels":advertised}) ); } if enabled && known_host { diff --git a/crates/buzz-relay/src/api/git/policy.rs b/crates/buzz-relay/src/api/git/policy.rs index 1e376501ab1..6b428ee8ba7 100644 --- a/crates/buzz-relay/src/api/git/policy.rs +++ b/crates/buzz-relay/src/api/git/policy.rs @@ -885,7 +885,7 @@ printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "{secret}" -hex 2>/dev/nu .expect("sign 30617"); state .db - .insert_event(community, &event, None) + .insert_event(community, &event, None, buzz_db::ArchiveGate::Exempt) .await .expect("insert 30617"); diff --git a/crates/buzz-relay/src/api/git/settings_tests.rs b/crates/buzz-relay/src/api/git/settings_tests.rs index a1dd438e227..30a2a65f1e4 100644 --- a/crates/buzz-relay/src/api/git/settings_tests.rs +++ b/crates/buzz-relay/src/api/git/settings_tests.rs @@ -208,11 +208,7 @@ mod postgres_tests { // // Same P-256 key as buzz-auth/src/nip_fi/verifier/tests.rs so // the construction pattern can be reviewed against a known-good example. - const TEST_EC_PKCS8_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ - WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ - zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ - -----END PRIVATE KEY-----\n"; + const TEST_EC_PKCS8_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\nWZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\nzhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n-----END PRIVATE KEY-----\n"; // sadscan:disable np.pem.1 kingfisher.privkey.2 -- public P-256 test fixture, same key as buzz-auth nip_fi verifier tests const TEST_JWK_X: &str = "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI"; const TEST_JWK_Y: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"; const TEST_KID: &str = "test-key-1"; @@ -1283,7 +1279,7 @@ mod external_infra { .unwrap(); state .db - .insert_event(community, &announcement, None) + .insert_event(community, &announcement, None, buzz_db::ArchiveGate::Exempt) .await .unwrap(); let f = Self { @@ -2059,11 +2055,8 @@ mod external_infra { const NIP_FI_ISSUER: &str = "https://nip-fi-settings-test.invalid"; const NIP_FI_AUDIENCE: &str = "https://relay.settings-test.invalid"; const NIP_FI_KID: &str = "settings-test-key-1"; - const NIP_FI_EC_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ - WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ - zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ - -----END PRIVATE KEY-----\n"; + const NIP_FI_EC_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\nWZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\nzhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n-----END PRIVATE KEY-----\n"; // sadscan:disable np.pem.1 kingfisher.privkey.2 -- public P-256 test fixture, same key as buzz-auth nip_fi verifier tests + // Public key coordinates for the JWK (matches the private key above). const NIP_FI_JWK_X: &str = "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI"; const NIP_FI_JWK_Y: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"; diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 0df9101bc0a..3e75c20caf2 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -3572,7 +3572,7 @@ mod sec005_postgres_tests { } } let event = announcement(&owner_keys, tags); - db.insert_event(community, &event, None) + db.insert_event(community, &event, None, buzz_db::ArchiveGate::Exempt) .await .expect("30617"); diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 5ff34bdc74a..e567013ac90 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -3316,6 +3316,23 @@ async fn commit_participant_join( .begin_event_write_transaction(tenant.community()) .await?; + // 2a. The write gates of the huddle and its lifecycle parent come first + // (see `buzz_db::lifecycle`): an archive of either commits before + // this check, which then refuses the join, or after this join. + let gated = [channel_id, parent_channel_id]; + match buzz_db::lifecycle::lock_write_gates(&mut tx, buzz_db::ArchiveGate::Refuse, &gated).await + { + Ok(_) => {} + Err(error) if crate::handlers::ingest::is_archived_refusal(&error) => { + retire_shadow(); + #[cfg(test)] + crate::nip_fi_test_hooks::before_join_refusal_rollback(tenant.community()).await; + let _ = tx.rollback().await; + return Err(JoinCommitError::Archived); + } + Err(error) => return Err(error.into()), + } + // 3. Lock order. Every writer that takes more than one of a channel's // locks takes them as: membership lock, then TTL lock, then the // channel row (see `buzz_db::channel::update_channel`). This join @@ -3791,7 +3808,13 @@ async fn emit_participant_event( // for the duration of the Redis pub/sub delivery and are lost forever. let stored = match state .db - .insert_event(tenant.community(), &event, Some(parent_channel_id)) + .insert_event( + tenant.community(), + &event, + Some(parent_channel_id), + // A relay huddle notice: it records state that already changed. + buzz_db::ArchiveGate::Exempt, + ) .await { Ok((stored, true)) => stored, diff --git a/crates/buzz-relay/src/handlers/artifact.rs b/crates/buzz-relay/src/handlers/artifact.rs index d5780e3f0a2..1d3d8972db0 100644 --- a/crates/buzz-relay/src/handlers/artifact.rs +++ b/crates/buzz-relay/src/handlers/artifact.rs @@ -45,9 +45,16 @@ pub(crate) async fn accept( source, &state.relay_keypair, state.config.lifecycle_guard_enabled, + buzz_db::ArchiveGate::Refuse, ) .await - .map_err(|e| IngestError::Internal(format!("artifact storage: {e}")))?; + .map_err(|e| { + // The home channel was archived after ingest's unlocked check. + if super::ingest::is_archived_refusal(&e) { + return super::ingest::archived_rejection(); + } + IngestError::Internal(format!("artifact storage: {e}")) + })?; match outcome { ArtifactOutcome::Accepted(events) => { for stored in &events { diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 2c5a8ff5b5c..873368770c0 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -21,13 +21,13 @@ use buzz_core::kind::*; use buzz_core::tenant::{CommunityId, TenantContext}; use buzz_datastore_tracing::datastore_span; use buzz_db::workflow::{ApprovalStatus, RunStatus}; -use buzz_db::DbError; +use buzz_db::{ArchiveGate, DbError}; use buzz_workflow::executor::TriggerContext; use crate::state::AppState; use crate::webhook_secret; -use super::ingest::{extract_channel_id, IngestAuth, IngestError, IngestResult}; +use super::ingest::{extract_channel_id, gated_write_error, IngestAuth, IngestError, IngestResult}; use super::side_effects::{ emit_group_discovery_events, emit_membership_notification, emit_system_message, publish_dm_visibility_snapshot, @@ -98,16 +98,26 @@ enum PersistResult { /// persists without the event record. On retry, the event INSERT succeeds /// (no conflict), and the mutation re-executes — which is safe for idempotent /// operations (open_dm, hide_dm, update_approval, upsert_workflow). +/// +/// Under [`ArchiveGate::Refuse`] the transaction first takes the write gate +/// of the event's channel and of `gate_channels` (and, for a replaceable +/// command, of the channels of its live versions), so a command into an +/// archived channel is refused and stores nothing. `gate_channels` does not +/// change where the event is stored. The mutations that follow take no gate. #[datastore_span(name = "persist_command_event", system = "postgresql")] async fn persist_command_event( db: &buzz_db::Db, tenant: &TenantContext, event: &Event, channel_id_override: Option, + gate: ArchiveGate, + gate_channels: &[Uuid], ) -> Result { use buzz_db::replaceable::{ParameterizedReplacePrecondition, ParameterizedReplaceStatus}; let channel_id = channel_id_override.or_else(|| extract_channel_id(event)); + let mut gated = gate_channels.to_vec(); + gated.extend(channel_id); let mut tx = db .begin_event_write_transaction(tenant.community()) .await @@ -127,6 +137,11 @@ async fn persist_command_event( buzz_db::event::D_TAG_MAX_LEN, ))); } + let gates = buzz_db::replaceable::lock_parameterized_write_gates( + &mut tx, gate, event, d_tag, &gated, + ) + .await + .map_err(|error| gated_write_error(error, "persist command event: "))?; let kind = event.kind.as_u16() as i32; let (expected_revision, revision_error) = match parse_expected_workflow_revision( @@ -150,11 +165,10 @@ async fn persist_command_event( d_tag, channel_id, precondition, + &gates, ) .await - .map_err(|e| { - IngestError::Internal(format!("error: replace parameterized event: {e}")) - })?; + .map_err(|error| gated_write_error(error, "persist command event: "))?; return match result.status { ParameterizedReplaceStatus::Inserted => Ok(PersistResult::Inserted(tx)), @@ -182,9 +196,12 @@ async fn persist_command_event( }; } + buzz_db::lifecycle::lock_write_gates(&mut tx, gate, &gated) + .await + .map_err(|error| gated_write_error(error, "persist command event: "))?; let (_, was_inserted) = buzz_db::event::insert_event_in_transaction(&mut tx, event, channel_id) .await - .map_err(|e| IngestError::Internal(format!("error: insert event: {e}")))?; + .map_err(|error| gated_write_error(error, "persist command event: "))?; if was_inserted { Ok(PersistResult::Inserted(tx)) } else { @@ -329,8 +346,13 @@ async fn handle_dm_open( } } - // Persist the command event (idempotency) — returns open transaction - let tx = match persist_command_event(&state.db, tenant, event, None).await? { + // Persist the command event (idempotency) — returns open transaction. + // Refuse gates only the event's own h channel. dm_open names its DM by + // participants, not by h, so re-opening an archived DM only clears the + // caller's own hidden_at (Q2) and is not refused. A new DM is a new channel. + let tx = match persist_command_event(&state.db, tenant, event, None, ArchiveGate::Refuse, &[]) + .await? + { PersistResult::Duplicate => { return Ok(IngestResult { event_id: event.id.to_hex(), @@ -492,7 +514,9 @@ async fn handle_dm_add_member( } // Persist the command event — returns open transaction - let tx = match persist_command_event(&state.db, tenant, event, None).await? { + let tx = match persist_command_event(&state.db, tenant, event, None, ArchiveGate::Refuse, &[]) + .await? + { PersistResult::Duplicate => { return Ok(IngestResult { event_id: event.id.to_hex(), @@ -597,8 +621,11 @@ async fn handle_dm_hide( return Err(IngestError::Rejected("invalid: channel is not a DM".into())); } - // Persist the command event — returns open transaction - let tx = match persist_command_event(&state.db, tenant, event, None).await? { + // Persist the command event — returns open transaction. Hiding a DM is + // the caller's own view state, so an archived DM does not refuse it (Q2). + let tx = match persist_command_event(&state.db, tenant, event, None, ArchiveGate::Exempt, &[]) + .await? + { PersistResult::Duplicate => { return Ok(IngestResult { event_id: event.id.to_hex(), @@ -750,7 +777,9 @@ async fn handle_workflow_def( let hash = compute_definition_hash(&definition_json_final); // Persist the command event — returns open transaction - let tx = match persist_command_event(&state.db, tenant, event, None).await? { + let tx = match persist_command_event(&state.db, tenant, event, None, ArchiveGate::Refuse, &[]) + .await? + { PersistResult::Duplicate => { return Ok(IngestResult { event_id: event.id.to_hex(), @@ -892,7 +921,16 @@ async fn handle_workflow_trigger( // Persist the command event under the workflow channel even though the // trigger event itself only carries the workflow UUID. Storing channel // triggers as global events leaks workflow IDs to unrelated relay members. - let tx = match persist_command_event(&state.db, tenant, event, workflow.channel_id).await? { + let mut tx = match persist_command_event( + &state.db, + tenant, + event, + workflow.channel_id, + ArchiveGate::Refuse, + &[], + ) + .await? + { PersistResult::Duplicate => { return Ok(IngestResult { event_id: event.id.to_hex(), @@ -902,6 +940,10 @@ async fn handle_workflow_trigger( } PersistResult::Inserted(tx) => tx, }; + // The binding read before T1 may be stale: hold it on T1 and check it. + buzz_db::workflow::verify_workflow_channel(&mut tx, workflow_id, wf_channel_id) + .await + .map_err(|error| gated_write_error(error, "verify workflow channel: "))?; // 4. Execute: create workflow run let mut trigger_ctx = TriggerContext { @@ -926,6 +968,8 @@ async fn handle_workflow_trigger( let trigger_ctx_json = serde_json::to_value(&trigger_ctx).ok(); let event_id_bytes = event.id.as_bytes().to_vec(); + // Exempt: T1 holds the gate and the binding. A gate on this second + // connection could queue behind an archive that waits for T1. let run_id = state .db .create_workflow_run( @@ -933,6 +977,7 @@ async fn handle_workflow_trigger( workflow_id, Some(&event_id_bytes), trigger_ctx_json.as_ref(), + ArchiveGate::Exempt, ) .await .map_err(|e| IngestError::Internal(format!("error: db create_workflow_run: {e}")))?; @@ -1032,6 +1077,34 @@ fn check_approver_spec(approver_spec: &str, requester_hex: &str) -> Result<(), I ))) } +/// The channel of the workflow `approval` belongs to, which the approval +/// command gates in its first transaction. A missing workflow or one with no +/// channel fails closed, so no approval takes an empty gate set. No re-read +/// under the gate: a rebind deletes the workflow, the delete cascades to the +/// approval row, `update_approval_by_stored_hash` returns false, and the +/// command fails before the T1 commit and the post-commit spawn. +async fn approval_gate_channel( + state: &AppState, + tenant: &TenantContext, + approval: &buzz_db::workflow::ApprovalRecord, +) -> Result { + match state + .db + .get_workflow(tenant.community(), approval.workflow_id) + .await + { + Ok(workflow) => workflow.channel_id.ok_or_else(|| { + IngestError::Rejected("forbidden: workflow has no channel scope".into()) + }), + Err(DbError::NotFound(_)) => { + Err(IngestError::Rejected("invalid: approval not found".into())) + } + Err(e) => Err(IngestError::Internal(format!( + "error: db get_workflow: {e}" + ))), + } +} + async fn handle_approval_grant( tenant: &TenantContext, state: &Arc, @@ -1075,8 +1148,19 @@ async fn handle_approval_grant( // 4. Validate caller is authorized approver check_approver_spec(&approval.approver_spec, &self_hex)?; - // Persist the command event — returns open transaction - let tx = match persist_command_event(&state.db, tenant, event, None).await? { + // Persist the command event — returns open transaction. It gates the + // workflow's channel, so an archived channel refuses the approval. + let channel = approval_gate_channel(state, tenant, &approval).await?; + let tx = match persist_command_event( + &state.db, + tenant, + event, + None, + ArchiveGate::Refuse, + &[channel], + ) + .await? + { PersistResult::Duplicate => { return Ok(IngestResult { event_id: event.id.to_hex(), @@ -1186,8 +1270,19 @@ async fn handle_approval_deny( // 4. Validate caller is authorized approver check_approver_spec(&approval.approver_spec, &self_hex)?; - // Persist the command event — returns open transaction - let tx = match persist_command_event(&state.db, tenant, event, None).await? { + // Persist the command event — returns open transaction. It gates the + // workflow's channel, so an archived channel refuses the approval. + let channel = approval_gate_channel(state, tenant, &approval).await?; + let tx = match persist_command_event( + &state.db, + tenant, + event, + None, + ArchiveGate::Refuse, + &[channel], + ) + .await? + { PersistResult::Duplicate => { return Ok(IngestResult { event_id: event.id.to_hex(), @@ -1504,7 +1599,15 @@ mod postgres_tests { Some(&missing_revision), "missing-revision", ); - let error = match persist_command_event(&db, &tenant, &missing_revision_update, None).await + let error = match persist_command_event( + &db, + &tenant, + &missing_revision_update, + None, + ArchiveGate::Exempt, + &[], + ) + .await { Err(error) => error, Ok(_) => panic!("missing revision must not create a workflow"), @@ -1515,15 +1618,16 @@ mod postgres_tests { if message == "conflict: workflow revision does not exist" )); - let PersistResult::Inserted(tx) = persist_command_event(&db, &tenant, &create, None) - .await - .expect("persist create") + let PersistResult::Inserted(tx) = + persist_command_event(&db, &tenant, &create, None, ArchiveGate::Exempt, &[]) + .await + .expect("persist create") else { panic!("first create must insert"); }; tx.commit().await.expect("commit create"); assert!(matches!( - persist_command_event(&db, &tenant, &create, None) + persist_command_event(&db, &tenant, &create, None, ArchiveGate::Exempt, &[]) .await .expect("replay create"), PersistResult::Duplicate @@ -1557,15 +1661,16 @@ mod postgres_tests { .find(|candidate| candidate.id.as_bytes() > update.id.as_bytes()) .expect("find same-second CAS-matching update dominated by current head"); - let PersistResult::Inserted(tx) = persist_command_event(&db, &tenant, &update, None) - .await - .expect("persist update") + let PersistResult::Inserted(tx) = + persist_command_event(&db, &tenant, &update, None, ArchiveGate::Exempt, &[]) + .await + .expect("persist update") else { panic!("matching update must insert"); }; tx.commit().await.expect("commit update"); assert!(matches!( - persist_command_event(&db, &tenant, &update, None) + persist_command_event(&db, &tenant, &update, None, ArchiveGate::Exempt, &[]) .await .expect("replay update"), PersistResult::Duplicate @@ -1578,7 +1683,16 @@ mod postgres_tests { Some(&create_revision), "stale-revision", ); - let error = match persist_command_event(&db, &tenant, &stale_revision_update, None).await { + let error = match persist_command_event( + &db, + &tenant, + &stale_revision_update, + None, + ArchiveGate::Exempt, + &[], + ) + .await + { Err(error) => error, Ok(_) => panic!("stale revision must not replace the current workflow"), }; @@ -1588,7 +1702,16 @@ mod postgres_tests { if message == "conflict: workflow changed since it was loaded" )); - let error = match persist_command_event(&db, &tenant, &dominated_update, None).await { + let error = match persist_command_event( + &db, + &tenant, + &dominated_update, + None, + ArchiveGate::Exempt, + &[], + ) + .await + { Err(error) => error, Ok(_) => panic!("distinct dominated CAS update must not report duplicate success"), }; @@ -1629,7 +1752,7 @@ mod postgres_tests { tx.commit().await.expect("commit legacy seed"); assert!(matches!( - persist_command_event(&db, &tenant, &legacy, None) + persist_command_event(&db, &tenant, &legacy, None, ArchiveGate::Exempt, &[]) .await .expect("exact legacy replay must remain idempotent"), PersistResult::Duplicate @@ -1642,10 +1765,13 @@ mod postgres_tests { Some("not-hex"), "distinct-malformed", ); - let error = match persist_command_event(&db, &tenant, &distinct, None).await { - Err(error) => error, - Ok(_) => panic!("distinct malformed revision must remain rejected"), - }; + let error = + match persist_command_event(&db, &tenant, &distinct, None, ArchiveGate::Exempt, &[]) + .await + { + Err(error) => error, + Ok(_) => panic!("distinct malformed revision must remain rejected"), + }; assert!(matches!( error, IngestError::Rejected(ref message) diff --git a/crates/buzz-relay/src/handlers/edit_metadata_postgres_tests.rs b/crates/buzz-relay/src/handlers/edit_metadata_postgres_tests.rs index 54283d5f86c..64a0dc5ebfb 100644 --- a/crates/buzz-relay/src/handlers/edit_metadata_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/edit_metadata_postgres_tests.rs @@ -733,8 +733,9 @@ async fn resend_validated_after_the_first_commit_is_a_duplicate() { } /// Two copies of one restore, sent at the same time, change the channel -/// once. Both are held at the channel's TTL lock until both wait there, so -/// both pass validation before either commits. One applies the restore; the +/// once. A held TTL lock stops both inside their write: one waits on the +/// TTL lock, the other on the archive gate behind it. So both pass +/// validation before either commits. One applies the restore; the /// other is reported as a duplicate, with no second notice. The case /// where a copy validates after the other commits is covered by /// `resend_validated_after_the_first_commit_is_a_duplicate`. @@ -781,17 +782,27 @@ async fn concurrent_copies_of_a_restore_apply_once() { }) .collect(); + // A restore takes the channel's archive gate (exclusive) before its TTL + // lock, so the first copy holds the gate and waits on the TTL lock, and + // the second copy waits on the gate. + let gate_key = format!( + "buzz_channel_gate:{}:{channel_id}", + tenant.community().as_uuid() + ); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); loop { - let waiting: i64 = sqlx::query_scalar( - "SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND NOT granted \ - AND ((classid::bigint << 32) | objid::bigint) = hashtextextended($1, 0)", + let (ttl_waiters, gate_waiters): (i64, i64) = sqlx::query_as( + "SELECT count(*) FILTER (WHERE key = hashtextextended($1, 0)), \ + count(*) FILTER (WHERE key = hashtextextended($2, 0)) \ + FROM (SELECT (classid::bigint << 32) | objid::bigint AS key FROM pg_locks \ + WHERE locktype = 'advisory' AND NOT granted) AS waiting", ) .bind(&lock_key) + .bind(&gate_key) .fetch_one(&pool) .await .expect("read lock waiters"); - if waiting == 2 { + if (ttl_waiters, gate_waiters) == (1, 1) { break; } assert!( @@ -800,7 +811,7 @@ async fn concurrent_copies_of_a_restore_apply_once() { ); assert!( std::time::Instant::now() < deadline, - "both copies never waited for the TTL lock" + "both copies never queued: {ttl_waiters} TTL-lock and {gate_waiters} gate waiters" ); tokio::time::sleep(std::time::Duration::from_millis(10)).await; } diff --git a/crates/buzz-relay/src/handlers/identity_archive.rs b/crates/buzz-relay/src/handlers/identity_archive.rs index 919c1467b69..9d3f360e93e 100644 --- a/crates/buzz-relay/src/handlers/identity_archive.rs +++ b/crates/buzz-relay/src/handlers/identity_archive.rs @@ -437,7 +437,7 @@ mod tests { async fn test_pool() -> Option { let url = std::env::var("DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".into()); + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".into()); // sadscan:disable np.postgres.1 -- local test fixture sqlx::PgPool::connect(&url).await.ok() } @@ -729,7 +729,12 @@ mod tests { let live_profile = profile_event(&target_keys, auth_tag(&owner_keys, &target_pubkey), now); state .db - .replace_addressable_event(tenant.community(), &live_profile, None) + .replace_addressable_event( + tenant.community(), + &live_profile, + None, + buzz_db::ArchiveGate::Exempt, + ) .await .expect("insert initial target kind:0"); @@ -754,7 +759,12 @@ mod tests { ); state .db - .replace_addressable_event(tenant.community(), &revoked_profile, None) + .replace_addressable_event( + tenant.community(), + &revoked_profile, + None, + buzz_db::ArchiveGate::Exempt, + ) .await .expect("replace target kind:0"); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 9cb33a61ade..0b869daafe0 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -977,6 +977,63 @@ pub(crate) fn non_member_may_post(visibility: &str, join_role: &str) -> bool { visibility == "open" && ChannelJoinRole::parse_fail_closed(join_role) == ChannelJoinRole::Member } +/// Whether `error` is a write refused under its channel's write gate because +/// the channel is archived (see [`buzz_db::lifecycle::lock_write_gates`]). +pub(crate) fn is_archived_refusal(error: &buzz_db::DbError) -> bool { + matches!(error, buzz_db::DbError::AccessDenied(reason) + if reason == buzz_db::lifecycle::CHANNEL_ARCHIVED) +} + +/// The reply to a write refused because its channel is archived: the same +/// text as the unlocked archived check. +pub(crate) fn archived_rejection() -> IngestError { + IngestError::Rejected(format!("invalid: {}", buzz_db::lifecycle::CHANNEL_ARCHIVED)) +} + +/// The reply to a failed gated write: an archived channel keeps the text of +/// the unlocked archived check, a lifecycle race is a 409, and anything else +/// is ours, as `error: {context}{error}`. +pub(crate) fn gated_write_error(error: buzz_db::DbError, context: &str) -> IngestError { + match error { + error if is_archived_refusal(&error) => archived_rejection(), + buzz_db::DbError::LifecycleConflict => { + IngestError::CanvasConflict("conflict: lifecycle changed".into()) + } + error => IngestError::Internal(format!("error: {context}{error}")), + } +} + +/// Store `event`, whose kind stores and applies itself in one transaction, +/// and apply it: a kind:9001 that removes another member, kind:9021, +/// kind:9005 or kind:5. Live effects run after the commit. +async fn persist_applied_event( + tenant: &TenantContext, + event: &Event, + state: &Arc, + kind: u32, + channel_id: Option, + thread_meta: Option>, +) -> Result<(buzz_core::StoredEvent, bool), buzz_db::DbError> { + use crate::handlers::side_effects as effects; + match kind { + KIND_NIP29_REMOVE_USER => { + effects::persist_remove_user(tenant, event, state, thread_meta).await + } + KIND_NIP29_JOIN_REQUEST => { + effects::persist_join_request(tenant, event, state, thread_meta).await + } + KIND_NIP29_DELETE_EVENT => { + effects::persist_delete_event(tenant, event, state, thread_meta).await + } + KIND_DELETION => { + effects::persist_standard_deletion(tenant, event, state, channel_id, thread_meta).await + } + other => Err(buzz_db::DbError::InvalidData(format!( + "kind:{other} is not applied in its own write" + ))), + } +} + /// The kind-9 channel write gates (token scope, membership or open channel, /// not archived) for a channel other than the event's own `h`. pub(crate) async fn check_channel_write( @@ -3472,10 +3529,16 @@ async fn ingest_event_inner( &target_id, &actor_bytes, emoji, + buzz_db::ArchiveGate::Refuse, ) .await - .map_err(|e| IngestError::Internal(format!("error: {e}")))? - { + .map_err(|e| { + // The target's channel was archived after the unlocked check. + if is_archived_refusal(&e) { + return target_denied(REACTION_TARGET_NOT_FOUND); + } + IngestError::Internal(format!("error: {e}")) + })? { buzz_db::ReactionEventInsertOutcome::TargetMissing => { return Err(IngestError::Rejected(format!( "invalid: {REACTION_TARGET_NOT_FOUND}" @@ -3548,6 +3611,22 @@ async fn ingest_event_inner( }; let workflow_deletion = crate::handlers::side_effects::is_workflow_deletion(&event); + let self_departure = crate::handlers::side_effects::is_self_departure(&event); + // These kinds store the event and apply it in one transaction, gated + // against a concurrent archive (see the dispatch below). + let applies_in_write = match kind_u32 { + KIND_NIP29_REMOVE_USER => !self_departure, + KIND_NIP29_JOIN_REQUEST | KIND_NIP29_DELETE_EVENT | KIND_DELETION => true, + _ => false, + }; + // Every client write into an archived channel is refused under the + // channel's write gate, except a member leaving by themselves. + // kind:9002 decides the archive state in its own write. + let archive_gate = if self_departure { + buzz_db::ArchiveGate::Exempt + } else { + buzz_db::ArchiveGate::Refuse + }; let mut message = String::new(); let (stored_event, was_inserted) = if let Some(create) = channel_create.as_ref() { let creator = event.pubkey.to_bytes(); @@ -3602,7 +3681,15 @@ async fn ingest_event_inner( // divide insertion and repair ownership between two relay workers. crate::handlers::side_effects::persist_workflow_deletion(tenant, &event, state) .await - .map_err(|e| IngestError::Internal(format!("error: workflow deletion failed: {e}")))? + .map_err(|e| match e.downcast_ref::() { + Some(error) if is_archived_refusal(error) => { + target_denied(crate::handlers::side_effects::DELETION_TARGET_DENIED) + } + Some(buzz_db::DbError::LifecycleConflict) => { + IngestError::CanvasConflict("conflict: lifecycle changed".into()) + } + _ => IngestError::Internal(format!("error: workflow deletion failed: {e}")), + })? } else if let Some(plan) = put_user { // One commit stores the event and applies its membership changes. A // failure stores nothing, so the client is told and can resend it. @@ -3657,14 +3744,39 @@ async fn ingest_event_inner( } other => IngestError::Internal(format!("error: channel delete failed: {other}")), })? + } else if applies_in_write { + // One commit stores the event and applies it (a removal, a join or a + // deletion); live effects run after it. A refusal decided under the + // locks stores nothing and is the client's error; anything else is + // ours. + let thread_params = thread_meta.as_ref().map(|m| m.as_params()); + persist_applied_event(tenant, &event, state, kind_u32, channel_id, thread_params) + .await + .map_err(|e| match e { + e if kind_u32 == KIND_DELETION && is_archived_refusal(&e) => { + target_denied(crate::handlers::side_effects::DELETION_TARGET_DENIED) + } + buzz_db::DbError::AccessDenied(reason) + if reason == buzz_db::channel_members::CHANNEL_PRIVATE => + { + IngestError::Rejected("restricted: channel is private".into()) + } + buzz_db::DbError::AccessDenied(reason) => { + IngestError::Rejected(format!("invalid: {reason}")) + } + buzz_db::DbError::LifecycleConflict => { + IngestError::CanvasConflict("conflict: lifecycle changed".into()) + } + other => IngestError::Internal(format!("error: database error: {other}")), + })? } else if buzz_core::kind::is_replaceable(kind_u32) { // NIP-16 replaceable event — atomic replace with stale-write protection. // channel_id is None for global kinds (0, 1, 3) due to step 5b above. state .db - .replace_addressable_event(tenant.community(), &event, channel_id) + .replace_addressable_event(tenant.community(), &event, channel_id, archive_gate) .await - .map_err(|e| IngestError::Internal(format!("error: {e}")))? + .map_err(|error| gated_write_error(error, ""))? } else if is_parameterized_replaceable(kind_u32) { // NIP-33 parameterized replaceable — keyed by (kind, pubkey, d_tag). let d_tag = buzz_db::event::extract_d_tag(&event).unwrap_or_default(); @@ -3677,9 +3789,15 @@ async fn ingest_event_inner( } state .db - .replace_parameterized_event(tenant.community(), &event, &d_tag, channel_id) + .replace_parameterized_event( + tenant.community(), + &event, + &d_tag, + channel_id, + archive_gate, + ) .await - .map_err(|e| IngestError::Internal(format!("error: {e}")))? + .map_err(|error| gated_write_error(error, ""))? } else if let Some(spec) = canvas_revision_spec.as_ref() { // Canvas write carrying an optimistic-concurrency precondition. Plain // canvas writes (no `expected-revision` tag) fall through to the generic @@ -3694,9 +3812,15 @@ async fn ingest_event_inner( }; let (stored_event, status) = state .db - .insert_canvas_head_checked(tenant.community(), &event, channel, precondition) + .insert_canvas_head_checked( + tenant.community(), + &event, + channel, + precondition, + archive_gate, + ) .await - .map_err(|e| IngestError::Internal(format!("error: {e}")))?; + .map_err(|error| gated_write_error(error, ""))?; match status { buzz_db::ChannelHeadWriteStatus::RevisionMissing => { return Err(IngestError::CanvasConflict( @@ -3725,6 +3849,7 @@ async fn ingest_event_inner( &event, channel_id, thread_params, + archive_gate, ) .await { @@ -3734,6 +3859,7 @@ async fn ingest_event_inner( buzz_db::DbError::AuthEventRejected => { IngestError::Rejected("invalid: AUTH events cannot be stored".into()) } + e if is_archived_refusal(&e) => archived_rejection(), other => IngestError::Internal(format!("error: database error: {other}")), }); } @@ -3748,9 +3874,11 @@ async fn ingest_event_inner( }); } - // Workflow deletion, kind:9000 and kind:9002 applied their changes in the - // insert's own transaction above. + // Workflow deletion, kind:9000, kind:9002 and the kinds of + // `applies_in_write` applied their changes in the insert's own + // transaction above. let persisted_atomically = workflow_deletion + || applies_in_write || kind_u32 == KIND_NIP29_PUT_USER || kind_u32 == KIND_NIP29_EDIT_METADATA; if !persisted_atomically && crate::handlers::side_effects::is_side_effect_kind(kind_u32) { @@ -6846,6 +6974,7 @@ mod postgres_tests { None, &healthy.relay_keypair, false, + buzz_db::ArchiveGate::Exempt, ) .await .map_err(db_err)?; diff --git a/crates/buzz-relay/src/handlers/lifecycle_postgres_tests.rs b/crates/buzz-relay/src/handlers/lifecycle_postgres_tests.rs index 531625e4bfc..25b735b5414 100644 --- a/crates/buzz-relay/src/handlers/lifecycle_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/lifecycle_postgres_tests.rs @@ -21,6 +21,8 @@ use crate::handlers::group_state_postgres_tests::{community, create_channel, htt use crate::handlers::ingest::{ingest_event, IngestError, IngestResult}; use crate::state::AppState; +mod archive_gate_postgres_tests; + const GUARD_REQUIRED: &str = "invalid: lifecycle guard required"; const CHANGED: &str = "conflict: lifecycle changed"; const NOT_AUTHORIZED_S: &str = "invalid: actor not authorized for name/about/archived/visibility/ttl/label/join_role/unlisted changes"; diff --git a/crates/buzz-relay/src/handlers/lifecycle_postgres_tests/archive_gate_postgres_tests.rs b/crates/buzz-relay/src/handlers/lifecycle_postgres_tests/archive_gate_postgres_tests.rs new file mode 100644 index 00000000000..06efec27ed3 --- /dev/null +++ b/crates/buzz-relay/src/handlers/lifecycle_postgres_tests/archive_gate_postgres_tests.rs @@ -0,0 +1,1664 @@ +//! Postgres-backed tests for the per-channel archive gate (NEXUS-4111, +//! slice 2). +//! +//! A write that an archived channel refuses holds the channel's gate +//! shared and reads the archive state under it. Every archive writer holds +//! the gate exclusive. Races are ordered by a second session that holds the +//! gate, never by sleeps: the test takes the gate, starts the contender, +//! waits until `pg_locks` shows it queued on that exact key, then commits. + +use std::time::Duration; + +use axum::http::StatusCode; +use buzz_core::channel::MemberRole; +use buzz_db::workflow::{ApprovalStatus, RunStatus}; +use buzz_db::{ArchiveGate, DbError}; +use nostr::{Event, EventBuilder, EventId, Keys, Kind, Tag}; +use sha2::{Digest, Sha256}; +use sqlx::{Postgres, Transaction}; +use uuid::Uuid; + +use super::{ + admin_event, advisory, conflict, edit, invalid, lock_row, stamp, task_record, Fixture, Reply, + Subtree, +}; + +const GATE: &str = "buzz_channel_gate"; +const MEMBERSHIP: &str = "buzz_channel_membership"; +const ARCHIVED: &str = "invalid: channel is archived"; +const DELETION_DENIED: &str = "invalid: deletion target not found or not deletable by you"; +const WAIT: Duration = Duration::from_secs(10); + +/// An event with exactly `tags`: the builder keeps a `p` tag of the signer. +fn event(keys: &Keys, kind: u16, content: &str, tags: &[&[&str]]) -> Event { + EventBuilder::new(Kind::Custom(kind), content) + .tags(tags.iter().map(|t| Tag::parse(t.iter().copied()).unwrap())) + .allow_self_tagging() + .custom_created_at(stamp()) + .sign_with_keys(keys) + .unwrap() +} + +fn message(keys: &Keys, channel: Uuid, content: &str) -> Event { + event(keys, 9, content, &[&["h", &channel.to_string()]]) +} + +/// A `buzz.task` artifact revision: an artifact that is not a task. +fn artifact(keys: &Keys, home: Uuid, id: Uuid, op: &str, prev: Option<&Event>) -> Event { + let (id, home) = (id.to_string(), home.to_string()); + let prev = prev.map(|p| p.id.to_hex()); + let (d, h, op) = (["d", id.as_str()], ["h", home.as_str()], ["op", op]); + let mut tags: Vec<&[&str]> = vec![ + &["ar", "1"], + &d, + &h, + &["type", "buzz.task"], + &["title", "Review"], + &op, + ]; + let prev = prev.as_deref().map(|p| ["prev", p]); + if let Some(prev) = &prev { + tags.push(prev); + } + event(keys, 45010, "review", &tags) +} + +fn workflow(keys: &Keys, channel: Uuid, id: Uuid) -> Event { + event( + keys, + 30620, + "name: Gate\ntrigger:\n on: message_posted\nsteps:\n - id: s1\n action: send_message\n text: hi\n", + &[&["d", &id.to_string()], &["h", &channel.to_string()]], + ) +} + +/// A store reply as a client would see it. +fn db_reply(result: Result) -> Reply { + match result { + Ok(_) => Reply::Applied, + Err(e) if crate::handlers::ingest::is_archived_refusal(&e) => invalid(ARCHIVED), + Err(e) => Reply::Other(e.to_string()), + } +} + +async fn post(f: &Fixture, channel: Uuid) -> Event { + let posted = message(&f.owner, channel, "before the archive"); + assert_eq!(f.send(&f.owner, posted.clone()).await, Reply::Applied); + posted +} + +async fn add_member(f: &Fixture, channel: Uuid, keys: &Keys) { + f.state + .db + .add_member( + f.tenant.community(), + channel, + &keys.public_key().to_bytes(), + MemberRole::Member, + Some(&f.owner.public_key().to_bytes()), + ) + .await + .expect("add member"); +} + +async fn is_member(f: &Fixture, channel: Uuid, keys: &Keys) -> bool { + f.state + .db + .is_member(f.tenant.community(), channel, &keys.public_key().to_bytes()) + .await + .expect("read membership") +} + +/// Store a NIP-33 `version` in `channel` without the gate. +async fn seed_version(f: &Fixture, version: &Event, channel: Uuid) { + let d = buzz_db::event::extract_d_tag(version).expect("d tag"); + f.state + .db + .replace_parameterized_event( + f.tenant.community(), + version, + &d, + Some(channel), + buzz_db::ArchiveGate::Exempt, + ) + .await + .expect("seed version"); +} + +/// Open (or re-open) the DM of the owner and `peer`. +async fn open_dm(f: &Fixture, peer: &str) -> Uuid { + let open = event(&f.owner, 41010, "", &[&["p", peer]]); + let opened = crate::handlers::ingest::ingest_event( + &f.state, + &f.tenant, + open, + crate::handlers::group_state_postgres_tests::http_auth(&f.owner), + ) + .await + .expect("open DM"); + let body = opened + .message + .strip_prefix("response:") + .expect("DM open response"); + let body: serde_json::Value = serde_json::from_str(body).expect("DM open JSON"); + body["channel_id"] + .as_str() + .and_then(|id| id.parse().ok()) + .expect("DM channel id") +} + +/// An ephemeral channel whose TTL deadline has passed. +async fn expired_channel(f: &Fixture) -> Uuid { + let channel = f.channel().await; + assert_eq!( + f.send(&f.owner, edit(&f.owner, channel, &[&["ttl", "3600"]])) + .await, + Reply::Applied + ); + sqlx::query( + "UPDATE channels SET ttl_deadline = NOW() - interval '1 second' \ + WHERE community_id = $1 AND id = $2", + ) + .bind(f.tenant.community().as_uuid()) + .bind(channel) + .execute(f.pool()) + .await + .expect("expire the channel"); + channel +} + +async fn archive(f: &Fixture, channel: Uuid) { + sqlx::query("UPDATE channels SET archived_at = NOW() WHERE community_id = $1 AND id = $2") + .bind(f.tenant.community().as_uuid()) + .bind(channel) + .execute(f.pool()) + .await + .expect("archive"); +} + +/// Archive `channel` in the holder's open transaction. +async fn archive_in(holder: &mut Transaction<'static, Postgres>, f: &Fixture, channel: Uuid) { + sqlx::query("UPDATE channels SET archived_at = NOW() WHERE community_id = $1 AND id = $2") + .bind(f.tenant.community().as_uuid()) + .bind(channel) + .execute(&mut **holder) + .await + .expect("archive in the holder"); +} + +async fn advisory_shared(holder: &mut Transaction<'static, Postgres>, key: &str) { + sqlx::query("SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))") + .bind(key) + .execute(&mut **holder) + .await + .expect("hold advisory lock shared"); +} + +/// The modes of the requests that wait for the advisory lock `key`. +async fn waiting(f: &Fixture, key: &str) -> Vec { + sqlx::query_scalar( + "SELECT mode FROM pg_locks \ + WHERE locktype = 'advisory' AND NOT granted AND objsubid = 1 \ + AND ((classid::bigint << 32) | objid::bigint) = hashtextextended($1, 0) \ + ORDER BY mode", + ) + .bind(key) + .fetch_all(f.pool()) + .await + .expect("inspect advisory waiters") +} + +/// Wait until the requests that wait for `key` have exactly `modes`. +async fn queued(f: &Fixture, key: &str, modes: &[&str]) { + let mut seen = Vec::new(); + let reached = tokio::time::timeout(WAIT, async { + loop { + seen = waiting(f, key).await; + if seen == modes { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await; + assert!( + reached.is_ok(), + "waiters on {key}: {seen:?}, expected {modes:?}" + ); +} + +async fn stored(f: &Fixture, id: &EventId) -> bool { + sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM events WHERE community_id = $1 AND id = $2)") + .bind(f.tenant.community().as_uuid()) + .bind(id.as_bytes().as_slice()) + .fetch_one(f.pool()) + .await + .expect("read event") +} + +/// The tables a channel write can change. +const WRITTEN: &[&str] = &[ + "events", + "event_mentions", + "thread_metadata", + "reactions", + "channel_members", + "artifact_heads", + "artifact_revisions", + "artifact_parents", + "workflows", + "workflow_runs", + "workflow_approvals", + "parameterized_event_watermarks", + "push_match_queue", +]; + +/// A digest of every row this community has in [`WRITTEN`] and in +/// `channels`, without the archive time that the test itself sets. +async fn snapshot(f: &Fixture) -> Vec<(String, Option)> { + let mut digests = Vec::new(); + let channels = "(to_jsonb(t) - 'archived_at')::text"; + for (table, row) in WRITTEN + .iter() + .map(|table| (*table, "t::text")) + .chain([("channels", channels)]) + { + let digest = sqlx::query_scalar(sqlx::AssertSqlSafe(format!( + "SELECT md5(string_agg({row}, '|' ORDER BY {row})) FROM {table} AS t \ + WHERE community_id = $1" + ))) + .bind(f.tenant.community().as_uuid()) + .fetch_one(f.pool()) + .await + .unwrap_or_else(|e| panic!("snapshot {table}: {e}")); + digests.push((table.to_string(), digest)); + } + digests +} + +/// One write that a concurrent archive must refuse. +#[derive(Clone, Copy, Debug)] +enum Row { + /// kind:9, the default insert. + Message, + Reaction, + /// kind:40100 with `expected-revision`. + CanvasHead, + /// NIP-16, straight to the store: no client kind is channel-scoped. + Addressable, + /// NIP-33, straight to the store. + Parameterized, + /// NIP-33 into an open channel, superseding a version in the archived one. + ParameterizedMove, + TaskRecord, + Artifact, + /// A move out of the archived channel. + ArtifactMove, + PutUser, + DeleteGroup, + /// A command's first transaction (a workflow definition). + Command, + /// Q3: adding a member to an archived DM. + DmAddMember, + /// A workflow `send_message` action. + Sink, + RemoveMember, + Join, + DeleteEvent, + /// kind:5 with an `e` target. + Deletion, + /// E1: a settings-only kind:9002 takes no gate; the row lock orders it. + SettingsEdit, + /// E2: kind:5 with an `a` target, gated by the version's channel. + CoordinateDeletion, + /// E3: kind:5 of a workflow in the archived channel. + WorkflowDeletion, + /// The roster auto-join (invite claim, kind:9030): the shared self-join. + AutoJoin, + /// The admission auto-join of a community without a roster. + FirstJoin, +} + +const ROWS: [Row; 23] = [ + Row::Message, + Row::Reaction, + Row::CanvasHead, + Row::Addressable, + Row::Parameterized, + Row::ParameterizedMove, + Row::TaskRecord, + Row::Artifact, + Row::ArtifactMove, + Row::PutUser, + Row::DeleteGroup, + Row::Command, + Row::DmAddMember, + Row::Sink, + Row::RemoveMember, + Row::Join, + Row::DeleteEvent, + Row::Deletion, + Row::SettingsEdit, + Row::CoordinateDeletion, + Row::WorkflowDeletion, + Row::AutoJoin, + Row::FirstJoin, +]; + +enum Write { + Client(Keys, Event), + Addressable(Event, Uuid), + Parameterized(Event, Uuid), + Sink(Uuid), + SelfJoin(Vec, Uuid), + FirstJoin(Vec, Uuid), +} + +struct Late { + /// The channel the race archives. + archived: Uuid, + write: Write, + expected: Reply, + /// `false` for a write that waits on the channel row, not the gate. + gated: bool, +} + +/// Build `row`'s state, before the archive starts. +async fn prepare(f: &Fixture, row: Row) -> Late { + let owner = f.owner.clone(); + let owner_hex = owner.public_key().to_hex(); + let channel = f.channel().await; + let h = channel.to_string(); + let client = |event: Event| Write::Client(owner.clone(), event); + let (archived, write, expected, gated) = match row { + Row::Message => ( + channel, + client(message(&owner, channel, "late")), + invalid(ARCHIVED), + true, + ), + Row::Reaction => { + let target = post(f, channel).await.id.to_hex(); + let reaction = event(&owner, 7, "+", &[&["e", &target]]); + let expected = invalid("invalid: reaction target event not found"); + (channel, client(reaction), expected, true) + } + Row::CanvasHead => { + let canvas = event( + &owner, + 40100, + "canvas", + &[&["h", &h], &["expected-revision", "none"]], + ); + (channel, client(canvas), invalid(ARCHIVED), true) + } + Row::Addressable => { + let replaceable = event(&owner, 10999, "", &[&["h", &h]]); + let write = Write::Addressable(replaceable, channel); + (channel, write, invalid(ARCHIVED), true) + } + Row::Parameterized => { + let version = event(&owner, 30999, "", &[&["d", "late"], &["h", &h]]); + let write = Write::Parameterized(version, channel); + (channel, write, invalid(ARCHIVED), true) + } + Row::ParameterizedMove => { + let open = f.channel().await; + let v1 = event(&owner, 30998, "v1", &[&["d", "moved"], &["h", &h]]); + seed_version(f, &v1, channel).await; + let v2 = event( + &owner, + 30998, + "v2", + &[&["d", "moved"], &["h", &open.to_string()]], + ); + ( + channel, + Write::Parameterized(v2, open), + invalid(ARCHIVED), + true, + ) + } + Row::TaskRecord => { + let (task, record) = f.task(None).await; + let update = task_record(&owner, task, None, "update", Some(&record)); + (task, client(update), invalid(ARCHIVED), true) + } + Row::Artifact => { + let create = artifact(&owner, channel, Uuid::new_v4(), "create", None); + (channel, client(create), invalid(ARCHIVED), true) + } + Row::ArtifactMove => { + let id = Uuid::new_v4(); + let create = artifact(&owner, channel, id, "create", None); + assert_eq!(f.send(&owner, create.clone()).await, Reply::Applied); + let open = f.channel().await; + let moved = artifact(&owner, open, id, "move", Some(&create)); + let expected = + Reply::Conflict(format!("conflict: {}", buzz_db::artifact::HEAD_UNAVAILABLE)); + (channel, client(moved), expected, true) + } + Row::PutUser => { + let newcomer = Keys::generate().public_key().to_hex(); + let put = admin_event(&owner, 9000, channel, &[&["p", &newcomer]]); + (channel, client(put), invalid(ARCHIVED), true) + } + Row::DeleteGroup => { + let delete = admin_event(&owner, 9008, channel, &[]); + (channel, client(delete), invalid(ARCHIVED), true) + } + Row::Command => { + let definition = workflow(&owner, channel, Uuid::new_v4()); + (channel, client(definition), invalid(ARCHIVED), true) + } + Row::DmAddMember => { + let dm = open_dm(f, &Keys::generate().public_key().to_hex()).await; + let added = Keys::generate().public_key().to_hex(); + let add = event( + &owner, + 41011, + "", + &[&["h", &dm.to_string()], &["p", &added]], + ); + (dm, client(add), invalid(ARCHIVED), true) + } + Row::Sink => (channel, Write::Sink(channel), invalid(ARCHIVED), true), + Row::RemoveMember => { + let member = Keys::generate(); + add_member(f, channel, &member).await; + let removed = member.public_key().to_hex(); + let remove = admin_event(&owner, 9001, channel, &[&["p", &removed]]); + (channel, client(remove), invalid(ARCHIVED), true) + } + Row::Join => { + let joiner = Keys::generate(); + let join = admin_event(&joiner, 9021, channel, &[]); + ( + channel, + Write::Client(joiner, join), + invalid(ARCHIVED), + true, + ) + } + Row::DeleteEvent => { + let target = post(f, channel).await.id.to_hex(); + let delete = admin_event(&owner, 9005, channel, &[&["e", &target]]); + (channel, client(delete), invalid(ARCHIVED), true) + } + Row::Deletion => { + let target = post(f, channel).await.id.to_hex(); + let delete = event(&owner, 5, "", &[&["e", &target]]); + (channel, client(delete), invalid(DELETION_DENIED), true) + } + Row::SettingsEdit => { + let rename = edit(&owner, channel, &[&["name", "renamed"]]); + (channel, client(rename), invalid(ARCHIVED), false) + } + Row::CoordinateDeletion => { + let version = event(&owner, 30997, "v", &[&["d", "gone"], &["h", &h]]); + seed_version(f, &version, channel).await; + let coordinate = format!("30997:{owner_hex}:gone"); + let delete = event(&owner, 5, "", &[&["a", &coordinate]]); + (channel, client(delete), invalid(DELETION_DENIED), true) + } + Row::WorkflowDeletion => { + let id = Uuid::new_v4(); + let definition = workflow(&owner, channel, id); + assert_eq!(f.send(&owner, definition).await, Reply::Applied); + let coordinate = format!("30620:{owner_hex}:{id}"); + let delete = event(&owner, 5, "", &[&["a", &coordinate]]); + (channel, client(delete), invalid(DELETION_DENIED), true) + } + Row::AutoJoin => { + let joiner = Keys::generate().public_key().to_bytes().to_vec(); + let write = Write::SelfJoin(joiner, channel); + (channel, write, invalid(ARCHIVED), true) + } + Row::FirstJoin => { + let joiner = Keys::generate().public_key().to_bytes().to_vec(); + let write = Write::FirstJoin(joiner, channel); + (channel, write, invalid(ARCHIVED), true) + } + }; + Late { + archived, + write, + expected, + gated, + } +} + +async fn run(f: &Fixture, write: Write) -> Reply { + let community = f.tenant.community(); + match write { + Write::Client(keys, event) => f.send(&keys, event).await, + Write::Addressable(event, channel) => db_reply( + f.state + .db + .replace_addressable_event( + community, + &event, + Some(channel), + buzz_db::ArchiveGate::Refuse, + ) + .await, + ), + Write::Parameterized(event, channel) => { + let d = buzz_db::event::extract_d_tag(&event).expect("d tag"); + db_reply( + f.state + .db + .replace_parameterized_event( + community, + &event, + &d, + Some(channel), + buzz_db::ArchiveGate::Refuse, + ) + .await, + ) + } + Write::Sink(channel) => { + use buzz_workflow::{ActionSink, ActionSinkError}; + let author = f.owner.public_key().to_hex(); + let sink = crate::workflow_sink::RelayActionSink::new(&f.state); + match sink + .send_message( + community, + &channel.to_string(), + "late", + "late", + &author, + None, + ) + .await + { + Ok(_) => Reply::Applied, + Err(ActionSinkError::ChannelArchived(_)) => invalid(ARCHIVED), + Err(e) => Reply::Other(e.to_string()), + } + } + Write::SelfJoin(pubkey, channel) => { + let joined = crate::handlers::side_effects::join_channel_as_self( + &f.tenant, &f.state, channel, &pubkey, + ) + .await; + match joined { + Ok(_) => Reply::Applied, + Err(e) => match e.downcast::() { + Ok(e) => db_reply::<()>(Err(e)), + Err(e) => Reply::Other(e.to_string()), + }, + } + } + Write::FirstJoin(pubkey, channel) => db_reply( + f.state + .db + .put_first_member(community, channel, &pubkey) + .await, + ), + } +} + +/// A write that passed its unlocked checks before an archive committed is +/// refused under the gate, and stores nothing: every refused kind, the +/// store seams no client kind reaches, the command's first transaction, the +/// workflow sink and both auto-join membership writes. A refused write waits +/// for the gate shared. A settings-only kind:9002 (E1) takes no gate and is +/// refused under the row lock. Mutations: drop the gate from a seam, or its +/// archived re-read, and that row stores its write or waits on the row +/// instead of the gate. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn late_writes_are_refused_by_a_concurrent_archive() { + let f = Fixture::new("archive-gate-late", true).await; + for row in ROWS { + let late = prepare(&f, row).await; + let event_id = match &late.write { + Write::Client(_, event) + | Write::Addressable(event, _) + | Write::Parameterized(event, _) => Some(event.id), + Write::Sink(_) | Write::SelfJoin(..) | Write::FirstJoin(..) => None, + }; + let before = snapshot(&f).await; + let gate = f.key(GATE, late.archived); + let mut holder = f.holder().await; + advisory(&mut holder, &gate).await; + archive_in(&mut holder, &f, late.archived).await; + + let attempt = { + let f = f.clone(); + tokio::spawn(async move { run(&f, late.write).await }) + }; + if late.gated { + queued(&f, &gate, &["ShareLock"]).await; + } else { + f.waiters(1).await; + assert!(waiting(&f, &gate).await.is_empty(), "{row:?} takes no gate"); + } + assert!(!attempt.is_finished(), "{row:?} did not wait"); + holder.commit().await.expect("commit the archive"); + + let reply = tokio::time::timeout(WAIT, attempt) + .await + .unwrap_or_else(|_| panic!("{row:?} never returned")) + .expect("the write must not panic"); + assert_eq!(reply, late.expected, "{row:?}"); + assert_eq!(snapshot(&f).await, before, "{row:?} stored nothing"); + if let Some(id) = event_id { + assert!(!stored(&f, &id).await, "{row:?}: the event is not stored"); + } + assert!(f.row(late.archived).await.archived, "{row:?}"); + } +} + +#[derive(Clone, Copy, Debug)] +enum Writer { + /// An unguarded kind:9002 archive. + Archive, + /// An unguarded kind:9002 restore. + Restore, + /// `archive_channel`, as the huddle auto-end calls it. + ArchiveChannel, + /// The TTL reaper. + Reaper, + /// A guarded archive of a task channel. + Guarded, +} + +/// Every archive writer takes the gate exclusive: it queues behind a write +/// that holds the gate shared and changes the channel after that write. +/// Mutation: take the gate shared, or not at all, in an archive writer and +/// its row never queues. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn archive_writers_take_the_gate_exclusive() { + let f = Fixture::new("archive-gate-writers", true).await; + let writers = [ + Writer::Archive, + Writer::Restore, + Writer::ArchiveChannel, + Writer::Reaper, + Writer::Guarded, + ]; + for writer in writers { + let channel = match writer { + Writer::Reaper => expired_channel(&f).await, + Writer::Guarded => f.task(None).await.0, + _ => f.channel().await, + }; + if matches!(writer, Writer::Restore) { + archive(&f, channel).await; + } + let guarded = match writer { + Writer::Guarded => Some(f.request(true, Subtree, channel, None, &[channel]).await), + _ => None, + }; + let gate = f.key(GATE, channel); + let mut holder = f.holder().await; + advisory_shared(&mut holder, &gate).await; + + let attempt = { + let f = f.clone(); + tokio::spawn(async move { + let community = f.tenant.community(); + match (writer, guarded) { + (Writer::Archive | Writer::Restore, _) => { + let value = if matches!(writer, Writer::Archive) { + "true" + } else { + "false" + }; + let change = edit(&f.owner, channel, &[&["archived", value]]); + f.send(&f.owner, change).await + } + (Writer::ArchiveChannel, _) => { + match f.state.db.archive_channel(community, channel).await { + Ok(true) => Reply::Applied, + other => Reply::Other(format!("{other:?}")), + } + } + (Writer::Reaper, _) => { + match f.state.db.reap_expired_ephemeral_channels(100).await { + Ok(reaped) if reaped.iter().any(|r| r.channel_id == channel) => { + Reply::Applied + } + other => Reply::Other(format!("{other:?}")), + } + } + (Writer::Guarded, Some(request)) => f.send(&f.owner, request).await, + (Writer::Guarded, None) => unreachable!("previewed above"), + } + }) + }; + queued(&f, &gate, &["ExclusiveLock"]).await; + assert!(!attempt.is_finished(), "{writer:?} did not wait"); + holder.commit().await.expect("release the shared gate"); + + let reply = tokio::time::timeout(WAIT, attempt) + .await + .unwrap_or_else(|_| panic!("{writer:?} never returned")) + .expect("the writer must not panic"); + assert_eq!(reply, Reply::Applied, "{writer:?}"); + let archived = !matches!(writer, Writer::Restore); + assert_eq!(f.row(channel).await.archived, archived, "{writer:?}"); + } +} + +/// V15: two writes into an expired TTL channel race the reaper, in both +/// orders, and nothing deadlocks. Writes queued before the reaper commit +/// first and refresh the deadline, so the reaper skips the channel. A +/// reaper queued first archives the channel, and the writes are refused. +/// Mutation: drop the reaper's deadline re-check and the first order +/// archives a refreshed channel. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn ttl_writes_and_the_reaper_serialize_in_both_orders() { + let f = Fixture::new("archive-gate-ttl", true).await; + for reaper_first in [false, true] { + let channel = expired_channel(&f).await; + let gate = f.key(GATE, channel); + let mut holder = f.holder().await; + advisory(&mut holder, &gate).await; + + let reap = || { + let f = f.clone(); + tokio::spawn(async move { + f.state + .db + .reap_expired_ephemeral_channels(100) + .await + .expect("reap") + .iter() + .any(|r| r.channel_id == channel) + }) + }; + let mut reaper = None; + let mut expected: Vec<&str> = Vec::new(); + if reaper_first { + reaper = Some(reap()); + expected.push("ExclusiveLock"); + queued(&f, &gate, &expected).await; + } + let mut writes = Vec::new(); + for n in 0..2 { + writes.push(f.spawn(&f.owner, message(&f.owner, channel, &format!("w{n}")))); + expected.push("ShareLock"); + expected.sort(); + queued(&f, &gate, &expected).await; + } + if !reaper_first { + reaper = Some(reap()); + expected.push("ExclusiveLock"); + expected.sort(); + queued(&f, &gate, &expected).await; + } + holder.commit().await.expect("release the gate"); + + let reaped = tokio::time::timeout(WAIT, reaper.expect("reaper started")) + .await + .expect("the reaper returns") + .expect("the reaper must not panic"); + assert_eq!(reaped, reaper_first, "reaper first: {reaper_first}"); + for write in writes { + let reply = tokio::time::timeout(WAIT, write) + .await + .expect("the write returns") + .expect("the write must not panic"); + let expected = if reaper_first { + invalid(ARCHIVED) + } else { + Reply::Applied + }; + assert_eq!(reply, expected, "reaper first: {reaper_first}"); + } + assert_eq!(f.row(channel).await.archived, reaper_first); + let messages = if reaper_first { 0 } else { 2 }; + assert_eq!(f.count(channel, 9).await, messages); + if !reaper_first { + let refreshed: bool = sqlx::query_scalar( + "SELECT ttl_deadline > NOW() FROM channels WHERE community_id = $1 AND id = $2", + ) + .bind(f.tenant.community().as_uuid()) + .bind(channel) + .fetch_one(f.pool()) + .await + .expect("read the deadline"); + assert!(refreshed, "the writes refreshed the deadline"); + } + } +} + +/// One write that an archived channel does not refuse. +#[derive(Clone, Copy, Debug)] +enum Exempt { + /// kind:9022 by a member. + Leave, + /// kind:9001 that names its own author. + SelfRemoval, + /// A relay notice (kind:40099). + Notice, + /// Relay discovery state (kind:39000-39002). + Discovery, + /// Q2: the caller's own DM view state. + DmHide, +} + +/// Writes that an archived channel does not refuse take no gate: each one +/// completes while an archive writer holds the channel's gate exclusive, +/// and stores its change. Mutation: refuse one of them and its row waits +/// for the gate. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn exempt_writes_take_no_gate() { + let f = Fixture::new("archive-gate-exempt", true).await; + let rows = [ + Exempt::Leave, + Exempt::SelfRemoval, + Exempt::Notice, + Exempt::Discovery, + Exempt::DmHide, + ]; + for row in rows { + let member = Keys::generate(); + let channel = match row { + Exempt::DmHide => open_dm(&f, &Keys::generate().public_key().to_hex()).await, + _ => f.channel().await, + }; + add_member(&f, channel, &member).await; + archive(&f, channel).await; + let gate = f.key(GATE, channel); + let mut holder = f.holder().await; + advisory(&mut holder, &gate).await; + + let attempt = { + let (f, member) = (f.clone(), member.clone()); + tokio::spawn(async move { + let (tenant, state) = (&f.tenant, &f.state); + let side_effects = |result: anyhow::Result<()>| match result { + Ok(()) => Reply::Applied, + Err(e) => Reply::Other(e.to_string()), + }; + match row { + Exempt::Leave => { + f.send(&member, admin_event(&member, 9022, channel, &[])) + .await + } + Exempt::SelfRemoval => { + let (h, me) = (channel.to_string(), member.public_key().to_hex()); + let remove = event(&member, 9001, "", &[&["h", &h], &["p", &me]]); + f.send(&member, remove).await + } + Exempt::Notice => side_effects( + crate::handlers::side_effects::emit_system_message( + tenant, + state, + channel, + serde_json::json!({"type": "gate_probe"}), + chrono::Utc::now(), + ) + .await, + ), + Exempt::Discovery => side_effects( + crate::handlers::side_effects::emit_group_discovery_events( + tenant, state, channel, + ) + .await, + ), + Exempt::DmHide => { + let hide = event(&f.owner, 41012, "", &[&["h", &channel.to_string()]]); + f.send(&f.owner, hide).await + } + } + }) + }; + let reply = tokio::time::timeout(WAIT, async { + while !attempt.is_finished() { + assert!( + waiting(&f, &gate).await.is_empty(), + "{row:?} waits for the gate" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + attempt.await.expect("the write must not panic") + }) + .await + .unwrap_or_else(|_| panic!("{row:?} never returned")); + holder.commit().await.expect("release the gate"); + assert_eq!(reply, Reply::Applied, "{row:?}"); + + match row { + Exempt::Leave | Exempt::SelfRemoval => { + assert!(!is_member(&f, channel, &member).await, "{row:?}"); + } + Exempt::Notice => assert_eq!(f.notices(channel, "gate_probe").await, 1), + Exempt::Discovery => { + let metadata = crate::handlers::group_state_postgres_tests::group_state_tags( + &f.state, &f.tenant, channel, 39000, + ) + .await; + assert!( + metadata.contains(&vec!["archived".to_string(), "true".to_string()]), + "{metadata:?}" + ); + } + Exempt::DmHide => assert_eq!(f.count(channel, 41012).await, 1), + } + assert!(f.row(channel).await.archived, "{row:?}"); + } +} + +/// Q1: a kind:9021 is stored and applied in one transaction, decided under +/// the membership lock. A channel made private while the join waits for +/// that lock refuses it, and nothing is stored: no event and no member. +/// Mutation: commit the event before the visibility check and the refused +/// join stays stored. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn a_join_refused_under_the_lock_stores_nothing() { + let f = Fixture::new("archive-gate-q1", true).await; + let channel = f.channel().await; + let joiner = Keys::generate(); + let join = admin_event(&joiner, 9021, channel, &[]); + let membership = f.key(MEMBERSHIP, channel); + let mut holder = f.holder().await; + advisory(&mut holder, &membership).await; + + let attempt = f.spawn(&joiner, join.clone()); + queued(&f, &membership, &["ExclusiveLock"]).await; + sqlx::query("UPDATE channels SET visibility = 'private' WHERE community_id = $1 AND id = $2") + .bind(f.tenant.community().as_uuid()) + .bind(channel) + .execute(&mut *holder) + .await + .expect("make the channel private"); + holder.commit().await.expect("release the membership lock"); + + let reply = tokio::time::timeout(WAIT, attempt) + .await + .expect("the join returns") + .expect("the join must not panic"); + assert_eq!(reply, invalid("restricted: channel is private")); + assert!( + !stored(&f, &join.id).await, + "the refused join is not stored" + ); + assert!(!is_member(&f, channel, &joiner).await); + assert_eq!(f.count(channel, 9021).await, 0); +} + +// -- G1 to G3: DMs, approvals and workflow runs ------------------------------- + +/// Poll `probe` until it holds. +async fn until>(what: &str, mut probe: impl FnMut() -> F) { + let reached = tokio::time::timeout(WAIT, async { + while !probe().await { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await; + assert!(reached.is_ok(), "never reached: {what}"); +} + +async fn join(attempt: tokio::task::JoinHandle) -> T { + tokio::time::timeout(WAIT, attempt) + .await + .expect("the attempt returns") + .expect("the attempt must not panic") +} + +async fn backend_pid(holder: &mut Transaction<'static, Postgres>) -> i32 { + sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut **holder) + .await + .expect("holder pid") +} + +/// Wait until a session that holds the gate `gate` shared waits for the +/// transaction of the session `pid`. +async fn waits_on(f: &Fixture, gate: &str, pid: i32) { + until( + &format!("a holder of {gate} waits on {pid}"), + || async move { + sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM pg_locks w JOIN pg_locks g ON g.pid = w.pid \ + WHERE w.locktype = 'transactionid' AND NOT w.granted \ + AND $1 = ANY (pg_blocking_pids(w.pid)) \ + AND g.locktype = 'advisory' AND g.granted AND g.mode = 'ShareLock' \ + AND g.objsubid = 1 \ + AND ((g.classid::bigint << 32) | g.objid::bigint) = hashtextextended($2, 0))", + ) + .bind(pid) + .bind(gate) + .fetch_one(f.pool()) + .await + .expect("inspect waits") + }, + ) + .await; +} + +/// The lock types that the sessions of this test's database wait for. +async fn pending(f: &Fixture) -> Vec { + sqlx::query_scalar( + "SELECT l.locktype FROM pg_locks l JOIN pg_stat_activity a ON a.pid = l.pid \ + WHERE NOT l.granted AND a.datname = current_database() ORDER BY 1", + ) + .fetch_all(f.pool()) + .await + .expect("inspect waits") +} + +/// Hold an uncommitted copy of `event` in `channel`: a write of the same +/// event waits at its insert until the holder rolls back. +async fn hold_insert( + holder: &mut Transaction<'static, Postgres>, + f: &Fixture, + event: &Event, + channel: Option, +) { + let created_at = chrono::DateTime::from_timestamp(event.created_at.as_secs() as i64, 0); + sqlx::query( + "INSERT INTO events \ + (community_id, id, pubkey, created_at, kind, tags, content, sig, channel_id, d_tag) \ + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10)", + ) + .bind(f.tenant.community().as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .bind(event.pubkey.to_bytes().as_slice()) + .bind(created_at.expect("timestamp")) + .bind(i32::from(event.kind.as_u16())) + .bind(serde_json::to_string(&event.tags).expect("tags")) + .bind(&event.content) + .bind(event.sig.serialize().as_slice()) + .bind(channel) + .bind(buzz_db::event::extract_d_tag(event)) + .execute(&mut **holder) + .await + .expect("hold the event's insert"); +} + +async fn lock_workflow(holder: &mut Transaction<'static, Postgres>, f: &Fixture, id: Uuid) { + sqlx::query("SELECT 1 FROM workflows WHERE community_id = $1 AND id = $2 FOR UPDATE") + .bind(f.tenant.community().as_uuid()) + .bind(id) + .execute(&mut **holder) + .await + .expect("hold the workflow row"); +} + +/// Rebind workflow `id` to `channel` in the holder's transaction, the only +/// way production can: delete the row, then create it again with the same id. +async fn rebind_in( + holder: &mut Transaction<'static, Postgres>, + f: &Fixture, + id: Uuid, + channel: Uuid, +) { + let row: String = sqlx::query_scalar( + "DELETE FROM workflows AS w WHERE community_id = $1 AND id = $2 RETURNING to_jsonb(w)::text", + ) + .bind(f.tenant.community().as_uuid()) + .bind(id) + .fetch_one(&mut **holder) + .await + .expect("delete the workflow"); + sqlx::query( + "INSERT INTO workflows SELECT * FROM jsonb_populate_record(NULL::workflows, \ + $1::jsonb || jsonb_build_object('channel_id', $2::uuid))", + ) + .bind(row) + .bind(channel) + .execute(&mut **holder) + .await + .expect("re-create the workflow"); +} + +/// Workflow `id` in `channel`, defined by the owner through the relay. +async fn define(f: &Fixture, channel: Uuid, id: Uuid) { + let definition = workflow(&f.owner, channel, id); + assert_eq!(f.send(&f.owner, definition).await, Reply::Applied); +} + +/// The owner's kind:5 of workflow `id`: an E3 deletion. +fn delete_workflow(f: &Fixture, id: Uuid) -> Event { + let coordinate = format!("30620:{}:{id}", f.owner.public_key().to_hex()); + event(&f.owner, 5, "", &[&["a", &coordinate]]) +} + +fn trigger(f: &Fixture, workflow: Uuid) -> Event { + event(&f.owner, 46020, "", &[&["d", &workflow.to_string()]]) +} + +async fn runs(f: &Fixture, workflow: Uuid) -> usize { + let community = f.tenant.community(); + let runs = f.state.db.list_workflow_runs(community, workflow, 10).await; + runs.expect("list runs").len() +} + +async fn create_run(f: &Fixture, workflow: Uuid) -> Result { + let community = f.tenant.community(); + let gate = ArchiveGate::Refuse; + f.state + .db + .create_workflow_run(community, workflow, None, None, gate) + .await +} + +const HOOK_SECRET: &str = "archive-gate-hook-secret"; + +/// A webhook workflow of the owner in `channel`, made without the relay. +async fn define_hook(f: &Fixture, channel: Option) -> Uuid { + let (db, community) = (&f.state.db, f.tenant.community()); + let owner = f.owner.public_key().to_bytes(); + db.ensure_user(community, &owner).await.expect("owner row"); + let definition = serde_json::json!({ + "name": "hook", + "trigger": {"on": "webhook"}, + "steps": [{"id": "s1", "action": "send_message", "text": "hi"}], + "_webhook_secret": HOOK_SECRET, + }) + .to_string(); + let created = db.create_workflow(community, channel, &owner, "hook", &definition, &[9; 32]); + created.await.expect("webhook workflow") +} + +/// Call the webhook of `workflow` with its secret: the status and the +/// error text, if any. +async fn webhook(f: &Fixture, workflow: Uuid) -> (StatusCode, Option) { + use axum::extract::{Path, Query, State}; + use axum::http::{header, HeaderMap, HeaderValue}; + let mut headers = HeaderMap::new(); + let host = HeaderValue::from_str(f.tenant.host()).expect("host header"); + headers.insert(header::HOST, host); + headers.insert("x-webhook-secret", HeaderValue::from_static(HOOK_SECRET)); + let query = Query(crate::api::bridge::WebhookQuery { secret: None }); + let id = Path(workflow.to_string()); + let body = axum::body::Bytes::new(); + let reply = + crate::api::bridge::workflow_webhook(State(f.state.clone()), id, query, headers, body); + let (Ok((status, axum::Json(body))) | Err((status, axum::Json(body)))) = reply.await; + (status, body["error"].as_str().map(str::to_owned)) +} + +fn spawn_archive(f: &Fixture, channel: Uuid) -> tokio::task::JoinHandle { + let f = f.clone(); + tokio::spawn(async move { + let community = f.tenant.community(); + f.state + .db + .archive_channel(community, channel) + .await + .expect("archive") + }) +} + +/// A run of `workflow` that waits on an approval anyone may give. Returns the +/// run and the `d` tag of a command on the approval. +async fn pending_approval(f: &Fixture, workflow: Uuid) -> (Uuid, String) { + let (db, community) = (&f.state.db, f.tenant.community()); + let run = db + .create_workflow_run(community, workflow, None, None, ArchiveGate::Exempt) + .await + .expect("run"); + let waiting = RunStatus::WaitingApproval; + db.update_workflow_run(community, run, waiting, 0, &serde_json::json!([]), None) + .await + .expect("the run waits"); + let token = Uuid::new_v4().to_string(); + db.create_approval(buzz_db::workflow::CreateApprovalParams { + community_id: community, + token: &token, + workflow_id: workflow, + run_id: run, + step_id: "s1", + step_index: 0, + approver_spec: "any", + expires_at: chrono::Utc::now() + chrono::Duration::hours(1), + }) + .await + .expect("approval"); + (run, hex::encode(Sha256::digest(token.as_bytes()))) +} + +/// G1: in an archived DM, a re-open only clears the caller's own hidden +/// state (Q2), so it succeeds and shows the DM again. An open that names the +/// archived DM in its `h` tag is refused and stores nothing. Adding a member +/// makes a new DM from the archived one, so it is refused and stores nothing. +/// Mutations: gate the channel of a re-opened DM and the re-open is refused; +/// dm_open under Exempt and the h-tagged open is stored. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn an_archived_dm_reopens_but_takes_no_new_member() { + let f = Fixture::new("archive-gate-dm", true).await; + let peer = Keys::generate().public_key().to_hex(); + let dm = open_dm(&f, &peer).await; + let hide = event(&f.owner, 41012, "", &[&["h", &dm.to_string()]]); + assert_eq!(f.send(&f.owner, hide).await, Reply::Applied); + archive(&f, dm).await; + let tagged = event( + &f.owner, + 41010, + "", + &[&["p", &peer], &["h", &dm.to_string()]], + ); + assert_eq!(f.send(&f.owner, tagged.clone()).await, invalid(ARCHIVED)); + assert!(!stored(&f, &tagged.id).await); + let hidden = || async { + sqlx::query_scalar::<_, bool>( + "SELECT hidden_at IS NOT NULL FROM channel_members \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3", + ) + .bind(f.tenant.community().as_uuid()) + .bind(dm) + .bind(f.owner.public_key().to_bytes().as_slice()) + .fetch_one(f.pool()) + .await + .expect("read hidden state") + }; + assert!(hidden().await); + assert_eq!(open_dm(&f, &peer).await, dm, "the archived DM re-opens"); + assert!(!hidden().await, "and is shown again"); + + let before = snapshot(&f).await; + let added = Keys::generate().public_key().to_hex(); + let add = event( + &f.owner, + 41011, + "", + &[&["h", &dm.to_string()], &["p", &added]], + ); + assert_eq!(f.send(&f.owner, add.clone()).await, invalid(ARCHIVED)); + assert_eq!(snapshot(&f).await, before, "no new DM"); + assert!(!stored(&f, &add.id).await); +} + +/// The state of an approval's workflow when its command runs. +#[derive(Clone, Copy, Debug)] +enum Approval { + /// The workflow's channel is archived. + Archived, + /// The workflow has no channel. + Unbound, + /// The workflow is deleted while the command reads it. + Missing, + /// The workflow is deleted and created again in another channel while + /// the command waits at its event insert. + Rebound, + /// The workflow is deleted (E3) and created again with the same id in + /// another channel before the command. + Recreated, +} + +/// G2: a grant or deny carries no `h` tag. It gates its workflow's channel, +/// read before its first transaction, so an archived channel refuses it. A +/// workflow with no channel, or one deleted while the command reads it, +/// fails closed. A rebind while the command waits, or before it, deletes the +/// approval with the workflow, so the command fails before it commits. Every refusal +/// stores no command event and leaves the run as it was: not resumed, not +/// cancelled. Mutations: gate no channel; take an empty gate set for an +/// unbound or missing workflow; commit when the approval update matched +/// nothing. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn approval_commands_gate_the_workflow_channel() { + let f = Fixture::new("archive-gate-approval", true).await; + let (db, community) = (&f.state.db, f.tenant.community()); + let rows = [ + (Approval::Archived, 46030), + (Approval::Archived, 46031), + (Approval::Unbound, 46030), + (Approval::Missing, 46031), + (Approval::Rebound, 46030), + (Approval::Rebound, 46031), + (Approval::Recreated, 46030), + (Approval::Recreated, 46031), + ]; + for (row, kind) in rows { + let channel = f.channel().await; + let workflow = match row { + Approval::Unbound => { + let owner = f.owner.public_key().to_bytes(); + let created = + db.create_workflow(community, None, &owner, "unbound", "{}", &[7; 32]); + created.await.expect("unbound workflow") + } + _ => { + let id = Uuid::new_v4(); + define(&f, channel, id).await; + id + } + }; + let (run, d) = pending_approval(&f, workflow).await; + let command = event(&f.owner, kind, "", &[&["d", &d]]); + let reply = match row { + Approval::Archived => { + archive(&f, channel).await; + f.send(&f.owner, command.clone()).await + } + Approval::Unbound => f.send(&f.owner, command.clone()).await, + Approval::Missing => { + let mut holder = f.holder().await; + sqlx::query("LOCK TABLE workflows IN ACCESS EXCLUSIVE MODE") + .execute(&mut *holder) + .await + .expect("pause workflow reads"); + let attempt = f.spawn(&f.owner, command.clone()); + f.waiters(1).await; + sqlx::query("DELETE FROM workflows WHERE community_id = $1 AND id = $2") + .bind(community.as_uuid()) + .bind(workflow) + .execute(&mut *holder) + .await + .expect("delete the workflow"); + holder.commit().await.expect("commit the delete"); + join(attempt).await + } + Approval::Rebound => { + let mut holder = f.holder().await; + hold_insert(&mut holder, &f, &command, None).await; + let pid = backend_pid(&mut holder).await; + let attempt = f.spawn(&f.owner, command.clone()); + waits_on(&f, &f.key(GATE, channel), pid).await; + let deletion = f.send(&f.owner, delete_workflow(&f, workflow)).await; + assert_eq!(deletion, Reply::Applied); + define(&f, f.channel().await, workflow).await; + holder.rollback().await.expect("release the insert"); + join(attempt).await + } + Approval::Recreated => { + let deletion = f.send(&f.owner, delete_workflow(&f, workflow)).await; + assert_eq!(deletion, Reply::Applied); + define(&f, f.channel().await, workflow).await; + f.send(&f.owner, command.clone()).await + } + }; + let expected = match row { + Approval::Archived => invalid(ARCHIVED), + Approval::Unbound => invalid("forbidden: workflow has no channel scope"), + Approval::Missing | Approval::Recreated => invalid("invalid: approval not found"), + Approval::Rebound => invalid("invalid: approval already acted on (race)"), + }; + assert_eq!(reply, expected, "{row:?} {kind}"); + assert!(!stored(&f, &command.id).await, "{row:?} {kind}: no event"); + let hash = hex::decode(&d).expect("token hash"); + let approval = db.get_approval_by_stored_hash(community, &hash).await; + let run = db.get_workflow_run(community, run).await; + match row { + Approval::Archived | Approval::Unbound => { + assert_eq!(approval.expect("approval").status, ApprovalStatus::Pending); + assert_eq!(run.expect("run").status, RunStatus::WaitingApproval); + } + // The delete took the approval and the run with the workflow. + Approval::Missing | Approval::Rebound | Approval::Recreated => { + assert!(approval.is_err() && run.is_err(), "{row:?} {kind}"); + assert_eq!(runs(&f, workflow).await, 0, "{row:?} {kind}"); + } + } + } +} + +/// G2: an approval holds its workflow channel's gate shared until it +/// commits. An archive that queues on the gate meanwhile does not hold up +/// the approval's second write, which takes no gate. The archive applies +/// after the approval commits. Mutation: gate no channel and the archive +/// never queues. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn an_archive_waits_for_an_approval_in_flight() { + let f = Fixture::new("archive-gate-approval-order", true).await; + let (db, community) = (&f.state.db, f.tenant.community()); + let channel = f.channel().await; + let workflow = Uuid::new_v4(); + define(&f, channel, workflow).await; + let (_, d) = pending_approval(&f, workflow).await; + let hash = hex::decode(&d).expect("token hash"); + let mut holder = f.holder().await; + sqlx::query( + "SELECT 1 FROM workflow_approvals WHERE community_id = $1 AND token = $2 FOR UPDATE", + ) + .bind(community.as_uuid()) + .bind(&hash) + .execute(&mut *holder) + .await + .expect("pause the approval update"); + let grant = f.spawn(&f.owner, event(&f.owner, 46030, "", &[&["d", &d]])); + f.waiters(1).await; + let archived = spawn_archive(&f, channel); + queued(&f, &f.key(GATE, channel), &["ExclusiveLock"]).await; + holder.commit().await.expect("release the approval"); + + assert_eq!(join(grant).await, Reply::Applied); + assert!(join(archived).await); + let approval = db.get_approval_by_stored_hash(community, &hash).await; + assert_eq!(approval.expect("approval").status, ApprovalStatus::Granted); + assert!(f.row(channel).await.archived); +} + +/// G3: a manual trigger reads its workflow's channel before its first +/// transaction, and checks the binding there under the gate. Here the +/// workflow is deleted (E3), created again in another channel, and that +/// channel is archived, while the trigger waits at its event insert. None of +/// them waits for the trigger. The trigger is refused with 409 and stores +/// nothing: no event, no run. Mutation: drop the binding check and the +/// workflow runs in the archived channel. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn a_manual_trigger_refuses_a_stale_binding() { + let f = Fixture::new("archive-gate-trigger-stale", true).await; + let (old, new) = (f.channel().await, f.channel().await); + let workflow = Uuid::new_v4(); + define(&f, old, workflow).await; + let fire = trigger(&f, workflow); + let mut holder = f.holder().await; + hold_insert(&mut holder, &f, &fire, Some(old)).await; + let pid = backend_pid(&mut holder).await; + let attempt = f.spawn(&f.owner, fire.clone()); + waits_on(&f, &f.key(GATE, old), pid).await; + + let rebind = async { + assert_eq!( + f.send(&f.owner, delete_workflow(&f, workflow)).await, + Reply::Applied + ); + define(&f, new, workflow).await; + assert!(f + .state + .db + .archive_channel(f.tenant.community(), new) + .await + .expect("archive")); + }; + let done = tokio::time::timeout(WAIT, rebind).await; + assert!( + done.is_ok(), + "the rebind and the archive wait for the trigger" + ); + assert!(!attempt.is_finished(), "the trigger still waits"); + holder.rollback().await.expect("release the insert"); + + assert_eq!(join(attempt).await, conflict()); + assert!(!stored(&f, &fire.id).await, "no trigger event"); + assert_eq!(runs(&f, workflow).await, 0, "no run"); +} + +/// G3: a manual trigger's run (its second write) takes no gate, so it +/// completes while an E3 deletion waits for the trigger's row lock and an +/// archive waits for its gate. A gated run would queue behind the archive, +/// which waits for the trigger: a cycle across two connections that Postgres +/// cannot see. One session holds the workflow row, so the trigger stops at +/// its binding check, after its gate. Another holds `workflow_runs`, so the +/// run stops at its insert. Mutation: create the run under Refuse and it +/// waits on the gate, so the wait never forms. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn a_manual_trigger_completes_behind_a_queued_archive() { + let f = Fixture::new("archive-gate-trigger-run", true).await; + let channel = f.channel().await; + let workflow = Uuid::new_v4(); + define(&f, channel, workflow).await; + let gate = f.key(GATE, channel); + let mut runs_holder = f.holder().await; + sqlx::query("LOCK TABLE workflow_runs IN SHARE MODE") + .execute(&mut *runs_holder) + .await + .expect("pause run inserts"); + let mut row_holder = f.holder().await; + lock_workflow(&mut row_holder, &f, workflow).await; + let pid = backend_pid(&mut row_holder).await; + + let fire = f.spawn(&f.owner, trigger(&f, workflow)); + waits_on(&f, &gate, pid).await; + let deletion = f.spawn(&f.owner, delete_workflow(&f, workflow)); + f.waiters(2).await; + let archived = spawn_archive(&f, channel); + queued(&f, &gate, &["ExclusiveLock"]).await; + row_holder + .rollback() + .await + .expect("release the workflow row"); + // The run waits at its insert, the deletion for the trigger's + // transaction, and the archive for the gate. + until("the run, the deletion and the archive wait", || async { + pending(&f).await == ["advisory", "relation", "transactionid"] + }) + .await; + runs_holder.commit().await.expect("release run inserts"); + + assert_eq!(join(fire).await, Reply::Applied); + assert_eq!(join(deletion).await, Reply::Applied); + assert!(join(archived).await); + assert!(f.row(channel).await.archived); +} + +/// G3: run creation and an archive serialize on the workflow channel's gate, +/// in both orders. Archive first: the creator queues on the gate and is +/// refused. Creator first (stopped at its binding check, after its gate): the +/// archive queues on the gate and applies after the run commits. A webhook +/// into an archived channel answers 404, like a missing workflow, and creates +/// no run. Mutations: create runs without the gate and the creator never +/// queues; create webhook runs under Exempt and the webhook runs. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn run_creation_and_archive_serialize_in_both_orders() { + let f = Fixture::new("archive-gate-run-order", true).await; + for archive_first in [true, false] { + let channel = f.channel().await; + let workflow = Uuid::new_v4(); + define(&f, channel, workflow).await; + let gate = f.key(GATE, channel); + let create = || { + let f = f.clone(); + tokio::spawn(async move { create_run(&f, workflow).await }) + }; + let mut holder = f.holder().await; + let (run, archived) = if archive_first { + lock_row(&mut holder, channel).await; + let archived = spawn_archive(&f, channel); + f.waiters(1).await; + let run = create(); + queued(&f, &gate, &["ShareLock"]).await; + (run, archived) + } else { + lock_workflow(&mut holder, &f, workflow).await; + let run = create(); + f.waiters(1).await; + let archived = spawn_archive(&f, channel); + queued(&f, &gate, &["ExclusiveLock"]).await; + (run, archived) + }; + holder.rollback().await.expect("release the holder"); + + let run = join(run).await; + assert!(join(archived).await, "archive first: {archive_first}"); + if archive_first { + assert_eq!(db_reply(run), invalid(ARCHIVED)); + assert_eq!(runs(&f, workflow).await, 0); + } else { + assert!(run.is_ok(), "{run:?}"); + assert_eq!(runs(&f, workflow).await, 1); + } + assert!(f.row(channel).await.archived); + } + + let channel = f.channel().await; + let hook = define_hook(&f, Some(channel)).await; + assert_eq!(webhook(&f, hook).await.0, StatusCode::ACCEPTED); + archive(&f, channel).await; + let not_found = (StatusCode::NOT_FOUND, Some("workflow not found".into())); + assert_eq!(webhook(&f, hook).await, not_found); + assert_eq!(runs(&f, hook).await, 1, "the archived webhook adds no run"); +} + +/// G3: under Refuse, a run or a schedule claim of a missing workflow, or of +/// one with no channel, fails closed before any gate and writes nothing. A +/// rebind (a delete and re-create of the same id) that commits while the +/// creator's re-read waits on the row gives `LifecycleConflict`: READ +/// COMMITTED skips the deleted row and does not see the new one. A webhook +/// answers 404 for a workflow with no channel (its own check answers first) +/// and 409 for the rebind, and creates no run. Mutations: take an empty gate +/// set for an unbound workflow; map no row at the re-read to unbound; create +/// webhook runs under Exempt. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn unbound_and_rebound_workflows_admit_nothing() { + let f = Fixture::new("archive-gate-unbound", true).await; + let (db, community) = (&f.state.db, f.tenant.community()); + let owner = f.owner.public_key().to_bytes(); + db.ensure_user(community, &owner).await.expect("owner row"); + let unbound = db.create_workflow(community, None, &owner, "unbound", "{}", &[7; 32]); + let unbound = unbound.await.expect("unbound workflow"); + for workflow in [Uuid::new_v4(), unbound] { + let now = chrono::Utc::now(); + let run = create_run(&f, workflow).await.map(drop); + let claim = db.claim_scheduled_workflow_fire(community, workflow, now, ArchiveGate::Refuse); + for result in [run, claim.await.map(drop)] { + assert!( + matches!(&result, Err(DbError::AccessDenied(reason)) + if reason == buzz_db::workflow::WORKFLOW_UNBOUND), + "{workflow}: {result:?}" + ); + } + } + assert_eq!(runs(&f, unbound).await, 0); + let anchor = db.latest_scheduled_workflow_fire(community, unbound).await; + assert_eq!(anchor.expect("anchor"), None, "no claim"); + + let (old, new) = (f.channel().await, f.channel().await); + let workflow = Uuid::new_v4(); + define(&f, old, workflow).await; + let mut holder = f.holder().await; + rebind_in(&mut holder, &f, workflow, new).await; + let pid = backend_pid(&mut holder).await; + let run = { + let f = f.clone(); + tokio::spawn(async move { create_run(&f, workflow).await }) + }; + waits_on(&f, &f.key(GATE, old), pid).await; + holder.commit().await.expect("commit the rebind"); + let run = join(run).await; + assert!(matches!(run, Err(DbError::LifecycleConflict)), "{run:?}"); + assert_eq!(runs(&f, workflow).await, 0); + + let unbound = define_hook(&f, None).await; + let not_found = (StatusCode::NOT_FOUND, Some("workflow not found".into())); + assert_eq!(webhook(&f, unbound).await, not_found); + assert_eq!(runs(&f, unbound).await, 0); + let (old, new) = (f.channel().await, f.channel().await); + let hook = define_hook(&f, Some(old)).await; + let mut holder = f.holder().await; + rebind_in(&mut holder, &f, hook, new).await; + let pid = backend_pid(&mut holder).await; + let posted = { + let f = f.clone(); + tokio::spawn(async move { webhook(&f, hook).await }) + }; + waits_on(&f, &f.key(GATE, old), pid).await; + holder.commit().await.expect("commit the rebind"); + let conflict = ( + StatusCode::CONFLICT, + Some("conflict: lifecycle changed".into()), + ); + assert_eq!(join(posted).await, conflict); + assert_eq!(runs(&f, hook).await, 0); +} diff --git a/crates/buzz-relay/src/handlers/moderation_notices.rs b/crates/buzz-relay/src/handlers/moderation_notices.rs index e5e4b9c3e3e..227c191cc1b 100644 --- a/crates/buzz-relay/src/handlers/moderation_notices.rs +++ b/crates/buzz-relay/src/handlers/moderation_notices.rs @@ -176,7 +176,13 @@ pub async fn send_moderation_notice( let (stored, _inserted) = state .db - .insert_event(tenant.community(), &event, Some(dm_channel_id)) + .insert_event( + tenant.community(), + &event, + Some(dm_channel_id), + // An operator moderation notice is never refused (Q6). + buzz_db::ArchiveGate::Exempt, + ) .await?; let kind_u32 = event_kind_u32(&stored.event); @@ -208,7 +214,12 @@ async fn publish_moderation_profile( // every other user profile so it is resolvable by any client. let (stored, was_inserted) = state .db - .replace_addressable_event(tenant.community(), &event, None) + .replace_addressable_event( + tenant.community(), + &event, + None, + buzz_db::ArchiveGate::Exempt, + ) .await?; if was_inserted { let kind_u32 = event_kind_u32(&stored.event); diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index a97000924e9..2374a1fc140 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -350,14 +350,19 @@ pub async fn handle_side_effects( ) -> anyhow::Result<()> { match kind { 0 => handle_kind0_profile(tenant, event, state).await, - 5 => handle_standard_deletion_event(tenant, event, state).await, + // kind:5, kind:9005, kind:9021 and a kind:9001 that removes another + // member belong to the atomic persistence paths in ingest + // (`persist_standard_deletion`, `persist_delete_event`, + // `persist_join_request`, `persist_remove_user`). + 5 => Err(anyhow::anyhow!("kind:5 requires atomic persistence")), // kind:9000 belongs to the atomic put-user path in ingest, which // reports each person's result. 9000 => Err(anyhow::anyhow!("kind:9000 requires persist_put_user")), - 9001 => handle_remove_user(tenant, event, state).await, + 9001 if is_self_departure(event) => handle_remove_user(tenant, event, state).await, + 9001 => Err(anyhow::anyhow!("kind:9001 requires atomic persistence")), // kind:9002 belongs to the atomic persistence path in ingest. 9002 => Err(anyhow::anyhow!("kind:9002 requires atomic persistence")), - 9005 => handle_delete_event_side_effect(tenant, event, state).await, + 9005 => Err(anyhow::anyhow!("kind:9005 requires atomic persistence")), 9007 => handle_create_group(tenant, event, state).await, 9008 => handle_delete_group(tenant, event, state).await, 9009 => { @@ -367,7 +372,7 @@ pub async fn handle_side_effects( ); Ok(()) } - 9021 => handle_join_request(tenant, event, state).await, + 9021 => Err(anyhow::anyhow!("kind:9021 requires atomic persistence")), 9022 => handle_leave_request(tenant, event, state).await, // NIP-34: Git repo announcement → reserve name + seed manifest pointer. KIND_GIT_REPO_ANNOUNCEMENT => handle_git_repo_announcement(tenant, event, state).await, @@ -1009,10 +1014,17 @@ pub async fn emit_system_message( ) -> anyhow::Result<()> { let event = sign_system_message(state, channel_id, content, idempotency_ts)?; - // Durable insert is the completion boundary — propagate failure. + // Durable insert is the completion boundary — propagate failure. A + // relay notice records a change that already committed, so an archived + // channel does not refuse it. state .db - .insert_event(tenant.community(), &event, Some(channel_id)) + .insert_event( + tenant.community(), + &event, + Some(channel_id), + buzz_db::ArchiveGate::Exempt, + ) .await .map_err(|e| anyhow::anyhow!("system message insert failed: {e}"))?; @@ -1258,7 +1270,12 @@ async fn emit_typed_membership_notification( // Store with channel_id = None → globally scoped, reachable by global subscribers. let (stored, was_inserted) = state .db - .insert_event(tenant.community(), &event, None) + .insert_event( + tenant.community(), + &event, + None, + buzz_db::ArchiveGate::Exempt, + ) .await?; if !was_inserted { return Ok(()); @@ -1407,9 +1424,15 @@ async fn emit_addressable_discovery_event( .sign_with_keys(&state.relay_keypair) .map_err(|e| anyhow::anyhow!("failed to sign kind:{kind}: {e}"))?; + // Relay discovery state: an archived channel still describes itself. let (stored, was_inserted) = state .db - .replace_addressable_event(tenant.community(), &event, Some(channel_id)) + .replace_addressable_event( + tenant.community(), + &event, + Some(channel_id), + buzz_db::ArchiveGate::Exempt, + ) .await?; if was_inserted { let kind_u32 = event_kind_u32(&stored.event); @@ -1966,7 +1989,14 @@ pub(crate) async fn persist_put_user( .collect(); let (stored, results) = match state .db - .put_members_with_event(tenant.community(), channel_id, event, &puts, thread_meta) + .put_members_with_event( + tenant.community(), + channel_id, + event, + &puts, + thread_meta, + buzz_db::ArchiveGate::Refuse, + ) .await? { buzz_db::channel_members::MemberPutEventWrite::Applied { stored, results } => { @@ -2095,6 +2125,9 @@ fn put_refusal_reason(error: buzz_db::DbError) -> String { } } +/// The effects of a stored kind:9001 that names its own author (see +/// [`is_self_departure`]). An archived channel does not refuse it, so it +/// keeps its own write after the event's. async fn handle_remove_user( tenant: &TenantContext, event: &Event, @@ -2102,30 +2135,84 @@ async fn handle_remove_user( ) -> anyhow::Result<()> { let channel_id = extract_h_tag_channel(event).ok_or_else(|| anyhow::anyhow!("missing h tag"))?; - let target_pubkey = extract_p_tag(event).ok_or_else(|| anyhow::anyhow!("missing p tag"))?; let actor_bytes = event.pubkey.to_bytes().to_vec(); // Guard: prevent last-owner orphaning on self-removal (kind 9001). - if target_pubkey == actor_bytes { - let members = state - .db - .get_members_for_event_write(tenant.community(), channel_id) - .await?; - if channel_authz::is_sole_owner(&members, &actor_bytes) { - return Err(ChannelAuthzError::LastOwnerRemovalTransferFirst.into()); - } + let members = state + .db + .get_members_for_event_write(tenant.community(), channel_id) + .await?; + if channel_authz::is_sole_owner(&members, &actor_bytes) { + return Err(ChannelAuthzError::LastOwnerRemovalTransferFirst.into()); } state .db - .remove_member(tenant.community(), channel_id, &target_pubkey, &actor_bytes) + .remove_member(tenant.community(), channel_id, &actor_bytes, &actor_bytes) .await?; - state.invalidate_membership(tenant, channel_id, &target_pubkey); - evict_live_channel_subscriptions(tenant, state, channel_id, &target_pubkey).await; - disable_departed_member_workflows(tenant, state, channel_id, &target_pubkey).await; + member_removed_effects(tenant, state, channel_id, &actor_bytes, &actor_bytes).await +} - let actor_hex = hex::encode(&actor_bytes); - let target_hex = hex::encode(&target_pubkey); +/// Store a kind:9001 that removes another member and remove them in one +/// transaction, then run the live effects. +/// +/// The channel's write gate comes first, so an archived channel refuses the +/// event and nothing is stored. A refusal decided under the membership lock +/// ([`buzz_db::DbError::AccessDenied`]) also stores nothing. A target that is +/// not an active member leaves the event stored with no effect. +pub(crate) async fn persist_remove_user( + tenant: &TenantContext, + event: &Event, + state: &Arc, + thread_meta: Option>, +) -> Result<(StoredEvent, bool), buzz_db::DbError> { + use buzz_db::channel_members::MemberRemoveEventWrite; + + let channel_id = extract_h_tag_channel(event) + .ok_or_else(|| buzz_db::DbError::InvalidData("missing h tag".into()))?; + let target = extract_p_tag(event) + .ok_or_else(|| buzz_db::DbError::InvalidData("missing p tag".into()))?; + let write = state + .db + .remove_member_with_event( + tenant.community(), + channel_id, + event, + &target, + thread_meta, + buzz_db::ArchiveGate::Refuse, + ) + .await?; + let stored = match write { + MemberRemoveEventWrite::Duplicate(stored) => return Ok((stored, false)), + MemberRemoveEventWrite::NotMember(stored) => { + info!(channel = %channel_id, "kind:9001 remove — target is not a member"); + return Ok((stored, true)); + } + MemberRemoveEventWrite::Removed(stored) => stored, + }; + let actor = event.pubkey.to_bytes(); + if let Err(e) = member_removed_effects(tenant, state, channel_id, &target, &actor).await { + error!(channel = %channel_id, "kind:9001 live effects failed: {e}"); + } + Ok((stored, true)) +} + +/// The live effects of `target`'s removal from `channel_id` by `actor`, after +/// the removal commits. +async fn member_removed_effects( + tenant: &TenantContext, + state: &Arc, + channel_id: Uuid, + target_pubkey: &[u8], + actor_bytes: &[u8], +) -> anyhow::Result<()> { + state.invalidate_membership(tenant, channel_id, target_pubkey); + evict_live_channel_subscriptions(tenant, state, channel_id, target_pubkey).await; + disable_departed_member_workflows(tenant, state, channel_id, target_pubkey).await; + + let actor_hex = hex::encode(actor_bytes); + let target_hex = hex::encode(target_pubkey); let msg_type = if target_pubkey == actor_bytes { "member_left" } else { @@ -2152,8 +2239,8 @@ async fn handle_remove_user( tenant, state, channel_id, - &target_pubkey, - &actor_bytes, + target_pubkey, + actor_bytes, KIND_MEMBER_REMOVED_NOTIFICATION, ) .await @@ -2546,87 +2633,59 @@ pub(crate) fn create_unlisted_from_tags(event: &Event) -> anyhow::Result { Ok(unlisted) } -async fn handle_delete_event_side_effect( +/// Store a kind:9005 and soft-delete its target in one transaction, then +/// emit the live thread summary and the `message_deleted` tombstone. +/// +/// The channel's write gate comes first, so an archived channel refuses the +/// event and nothing is stored. The target must live in the event's `h` +/// channel; a target in another channel, or none, leaves the event stored +/// with no effect. +pub(crate) async fn persist_delete_event( tenant: &TenantContext, event: &Event, state: &Arc, -) -> anyhow::Result<()> { - let channel_id = - extract_h_tag_channel(event).ok_or_else(|| anyhow::anyhow!("missing h tag"))?; - - // Extract target event ID from e tag - let target_id = event - .tags - .iter() - .find_map(|tag| { - if tag.kind().to_string() == "e" { - tag.content().and_then(|v| { - let bytes = hex::decode(v).ok()?; - if bytes.len() == 32 { - Some(bytes) - } else { - None - } - }) - } else { - None - } - }) - .ok_or_else(|| anyhow::anyhow!("missing e tag for target event"))?; - + thread_meta: Option>, +) -> Result<(StoredEvent, bool), buzz_db::DbError> { + let channel_id = extract_h_tag_channel(event) + .ok_or_else(|| buzz_db::DbError::InvalidData("missing h tag".into()))?; + let target_id = extract_target_event_ids(event).into_iter().next(); // Verify the target event belongs to the same channel as the h-tag. // Without this check, an admin of channel A could delete events in channel B // by sending h=A, e=. - if let Some(target_event) = state - .db - .get_event_by_id_including_deleted_for_event_write(tenant.community(), &target_id) - .await - .map_err(|e| anyhow::anyhow!("get_event_by_id failed: {e}"))? - { - match target_event.channel_id { - Some(target_ch) if target_ch != channel_id => { - return Err(anyhow::anyhow!( - "target event belongs to a different channel" - )); - } - None => { - return Err(anyhow::anyhow!("target event has no channel")); - } - _ => {} // Same channel — OK - } - } - - // Look up thread metadata so we can pass parent/root IDs to the - // transactional delete function. - let meta = state - .db - .get_thread_metadata_by_event(tenant.community(), &target_id) - .await - .map_err(|e| anyhow::anyhow!("get_thread_metadata failed: {e}"))?; - - let parent_id = meta.as_ref().and_then(|m| m.parent_event_id.clone()); - let root_id = meta.as_ref().and_then(|m| m.root_event_id.clone()); - - // Atomically soft-delete the event and decrement thread counters in one transaction. - let deleted = state + let target = target_id + .as_deref() + .map(|event_id| buzz_db::event_deletion::DeletionTarget { + event_id, + required_channel: Some(channel_id), + reaction: None, + }); + let write = state .db - .soft_delete_event_and_update_thread( + .insert_event_deletion( tenant.community(), - &target_id, - parent_id.as_deref(), - root_id.as_deref(), + event, + Some(channel_id), + thread_meta, + target, + buzz_db::ArchiveGate::Refuse, ) - .await - .map_err(|e| anyhow::anyhow!("soft_delete_event failed: {e}"))?; - - if !deleted { - warn!(target_event = %hex::encode(&target_id), "event already deleted or not found"); - return Ok(()); // No-op: skip system message to avoid false audit records. + .await?; + if !write.inserted { + return Ok((write.stored, false)); } + let Some(target_id) = target_id.filter(|_| write.deleted) else { + // No-op: skip system message to avoid false audit records. + warn!( + channel = %channel_id, + target_channel = ?write.target_channel, + "kind:9005 target missing, already deleted, or outside the channel" + ); + return Ok((write.stored, true)); + }; // Thread counters were decremented in the same transaction — push a fresh // relay-signed 39005 so live badge counts also count *down*. - if let Some(root_id) = root_id { + if let Some(root_id) = write.root_event_id { emit_live_thread_summary(tenant, state, channel_id, root_id); } @@ -2640,10 +2699,14 @@ async fn handle_delete_event_side_effect( copy_optional_string_field(event, &mut tombstone, "reason_code"); copy_optional_string_field(event, &mut tombstone, "public_reason"); - emit_system_message(tenant, state, channel_id, tombstone, chrono::Utc::now()).await?; + if let Err(e) = + emit_system_message(tenant, state, channel_id, tombstone, chrono::Utc::now()).await + { + error!(channel = %channel_id, "kind:9005 tombstone failed: {e}"); + } info!(target_event = %hex::encode(&target_id), "NIP-29 DELETE_EVENT processed"); - Ok(()) + Ok((write.stored, true)) } async fn handle_create_group( @@ -2813,7 +2876,12 @@ pub(crate) async fn persist_delete_group( .ok_or_else(|| buzz_db::DbError::InvalidData("missing h tag".into()))?; let write = state .db - .delete_channel_with_event(tenant.community(), channel_id, event) + .delete_channel_with_event( + tenant.community(), + channel_id, + event, + buzz_db::ArchiveGate::Refuse, + ) .await?; if write.inserted && !write.deleted { warn!(channel = %channel_id, "channel already deleted or not found"); @@ -2865,6 +2933,51 @@ async fn handle_delete_group( Ok(()) } +/// Store a kind:9021 and add its author to the open channel in one +/// transaction, then run the live effects. +/// +/// The channel's write gate comes first, so an archived channel refuses the +/// event and nothing is stored. A channel that is not open is refused under +/// the membership lock with [`buzz_db::channel_members::CHANNEL_PRIVATE`]. +/// An author who is already an active member leaves the event stored with no +/// effect. A self-join gets the channel's `join_role`: member in a normal +/// channel, guest in an announce channel, where a member must then make them +/// a member. +pub(crate) async fn persist_join_request( + tenant: &TenantContext, + event: &Event, + state: &Arc, + thread_meta: Option>, +) -> Result<(StoredEvent, bool), buzz_db::DbError> { + use buzz_db::channel_members::JoinEventWrite; + + let channel_id = extract_h_tag_channel(event) + .ok_or_else(|| buzz_db::DbError::InvalidData("missing h tag".into()))?; + let write = state + .db + .join_with_event( + tenant.community(), + channel_id, + event, + thread_meta, + buzz_db::ArchiveGate::Refuse, + ) + .await?; + let stored = match write { + JoinEventWrite::Duplicate(stored) => return Ok((stored, false)), + JoinEventWrite::AlreadyMember(stored) => { + info!(channel = %channel_id, "kind:9021 join — already a member, skipping"); + return Ok((stored, true)); + } + JoinEventWrite::Joined { stored, .. } => stored, + }; + if let Err(e) = announce_self_join(tenant, state, channel_id, &event.pubkey.to_bytes()).await { + error!(channel = %channel_id, "kind:9021 live effects failed: {e}"); + } + info!(channel = %channel_id, "kind:9021 join processed"); + Ok((stored, true)) +} + /// What [`join_channel_as_self`] did for the member. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SelfJoinOutcome { @@ -2876,8 +2989,8 @@ pub(crate) enum SelfJoinOutcome { /// Join `pubkey` to an open, unarchived channel as a self-join: no inviter, /// the channel's `join_role`, and the kind:40099, 39000/39002 and 44100 -/// side effects of a kind:9021. Shared by the kind:9021 handler and the -/// default-channel auto-join on new community memberships. +/// side effects of a kind:9021. Used by the default-channel auto-join on new +/// community memberships; a kind:9021 itself is [`persist_join_request`]. pub(crate) async fn join_channel_as_self( tenant: &TenantContext, state: &Arc, @@ -3118,24 +3231,6 @@ async fn join_never_joined_channels( } } -async fn handle_join_request( - tenant: &TenantContext, - event: &Event, - state: &Arc, -) -> anyhow::Result<()> { - let channel_id = - extract_h_tag_channel(event).ok_or_else(|| anyhow::anyhow!("missing h tag"))?; - match join_channel_as_self(tenant, state, channel_id, &event.pubkey.to_bytes()).await? { - SelfJoinOutcome::AlreadyMember => { - info!(channel = %channel_id, "kind:9021 join — already a member, skipping"); - } - SelfJoinOutcome::Joined => { - info!(channel = %channel_id, "kind:9021 join processed"); - } - } - Ok(()) -} - async fn handle_leave_request( tenant: &TenantContext, event: &Event, @@ -3220,6 +3315,9 @@ pub(crate) fn is_workflow_deletion(event: &Event) -> bool { } /// Persist an already-authorized workflow deletion and its domain changes atomically. +/// +/// The workflow's channel gate comes first, so a workflow in an archived +/// channel refuses the deletion and nothing is stored. pub(crate) async fn persist_workflow_deletion( tenant: &TenantContext, event: &Event, @@ -3239,7 +3337,13 @@ pub(crate) async fn persist_workflow_deletion( .ok_or_else(|| anyhow::anyhow!("invalid workflow coordinate"))?; let (stored, dispatch, channel_id) = state .db - .insert_workflow_deletion(tenant.community(), event, &owner, d_tag) + .insert_workflow_deletion( + tenant.community(), + event, + &owner, + d_tag, + buzz_db::ArchiveGate::Refuse, + ) .await?; if let Some(channel_id) = channel_id { state @@ -3249,223 +3353,229 @@ pub(crate) async fn persist_workflow_deletion( Ok((stored, dispatch)) } -/// Handle NIP-09 deletion via `a` tag (addressable/parameterized-replaceable events). -/// Parses "kind:pubkey:d-tag" and deletes the corresponding DB record. -async fn handle_a_tag_deletion( +/// Store a NIP-09 kind:5 and apply it in one transaction, then emit the live +/// thread summary. +/// +/// An `e` target is soft-deleted with its thread counters and, for a kind:7 +/// target, its reaction row. An `a` target of a parameterized kind +/// soft-deletes every version at or before the request (see +/// [`buzz_db::Db::insert_coordinate_deletion`]). A push lease target is +/// ignored: its revocation is a higher-generation inactive replacement. +/// Workflow coordinates use [`persist_workflow_deletion`]. +/// +/// The write gates of the request's channel and of every channel it deletes +/// from come first, so an archived one refuses the request and nothing is +/// stored. +pub(crate) async fn persist_standard_deletion( tenant: &TenantContext, event: &Event, state: &Arc, -) -> anyhow::Result<()> { - let a_value = event - .tags - .iter() - .find(|t| t.kind().to_string() == "a") - .and_then(|t| t.content().map(|s| s.to_string())) - .ok_or_else(|| anyhow::anyhow!("missing a tag for addressable deletion"))?; - - let parts: Vec<&str> = a_value.splitn(3, ':').collect(); - if parts.len() < 3 { - return Err(anyhow::anyhow!("invalid a-tag format: {a_value}")); + channel_id: Option, + thread_meta: Option>, +) -> Result<(StoredEvent, bool), buzz_db::DbError> { + if !has_e_tag(event) { + // NIP-09 a-tag deletion path for addressable events. Keyed on the + // absence of *any* e tag (not just valid e-ids): a malformed e + a must + // not route here and silently soft-delete the coordinate. + return persist_a_tag_deletion(tenant, event, state, channel_id, thread_meta).await; } - let kind_num: u32 = parts[0] - .parse() - .map_err(|_| anyhow::anyhow!("invalid kind in a-tag"))?; - let pubkey_hex = parts[1]; - let d_tag = parts[2]; - match kind_num { - // kind:30350 revocation is exclusively a higher-generation inactive replacement. - super::push_lease::KIND_PUSH_LEASE => { - tracing::debug!(d_tag, "NIP-09 deletion ignored for push lease"); - } - buzz_core::kind::KIND_WORKFLOW_DEF => { - // Workflow deletion belongs to the atomic persistence path in ingest. - return Err(anyhow::anyhow!( - "workflow deletion requires atomic persistence" - )); - } - // Other NIP-33 events have no executable workflow projection. - k if is_parameterized_replaceable(k) => { - let pubkey_bytes = match hex::decode(pubkey_hex) { - Ok(b) => b, - Err(e) => { - return Err(anyhow::anyhow!( - "invalid pubkey hex in a-tag {pubkey_hex}: {e}" - )); - } - }; - // Safe cast: NIP-33 kinds are 30000–39999, well within i32. - let kind_i32 = k as i32; - // NIP-09 scopes an a-tag deletion to versions at or before the - // deletion's own created_at, so a stale/replayed tombstone can never - // erase a newer replacement head. - let deleted = state + let target_event = match extract_target_event_ids(event).first() { + Some(target_id) => { + state .db - .soft_delete_by_coordinate( - tenant.community(), - kind_i32, - &pubkey_bytes, - d_tag, - event.created_at.as_secs() as i64, - ) - .await - .map_err(|e| { - anyhow::anyhow!( - "failed to soft-delete by coordinate {kind_i32}:{pubkey_hex}:{d_tag}: {e}" - ) - })?; - if deleted { - tracing::info!( - kind = k, - d_tag = d_tag, - "NIP-09 a-tag deletion: soft-deleted addressable event by coordinate" - ); - } else { - tracing::debug!( - kind = k, - d_tag = d_tag, - "NIP-09 a-tag deletion: no live row matched coordinate" - ); - } + .get_event_by_id_including_deleted_for_event_write(tenant.community(), target_id) + .await? } - _ => { + None => None, + }; + let target_event = target_event.filter(|target| { + let push_lease = + u32::from(target.event.kind.as_u16()) == super::push_lease::KIND_PUSH_LEASE; + if push_lease { tracing::debug!( - kind = kind_num, - d_tag = d_tag, - "NIP-09 a-tag deletion for non-NIP-33 kind — no side effect" + target_id = %target.event.id, + "NIP-09 deletion ignored for push lease" ); } + !push_lease + }); + let fallback = match &target_event { + Some(target) if u32::from(target.event.kind.as_u16()) == KIND_REACTION => { + reaction_row_of(tenant, state, &target.event).await? + } + _ => None, + }; + let target = target_event + .as_ref() + .map(|target| buzz_db::event_deletion::DeletionTarget { + event_id: target.event.id.as_bytes(), + required_channel: None, + reaction: Some(fallback.as_ref().map(ReactionRow::key)), + }); + let write = state + .db + .insert_event_deletion( + tenant.community(), + event, + channel_id, + thread_meta, + target, + buzz_db::ArchiveGate::Refuse, + ) + .await?; + // Thread counters were decremented in the same transaction — push a + // fresh relay-signed 39005 so live badge counts also count *down*. + if let (Some(root_id), Some(target_channel)) = (write.root_event_id, write.target_channel) { + emit_live_thread_summary(tenant, state, target_channel, root_id); } - - Ok(()) + Ok((write.stored, write.inserted)) } -async fn handle_standard_deletion_event( +/// The NIP-09 `a`-tag path of [`persist_standard_deletion`]. +async fn persist_a_tag_deletion( tenant: &TenantContext, event: &Event, state: &Arc, -) -> anyhow::Result<()> { - let target_ids = extract_target_event_ids(event); - if !has_e_tag(event) { - // NIP-09 a-tag deletion path for addressable events. Keyed on the - // absence of *any* e tag (not just valid e-ids): a malformed e + a must - // not route here and silently soft-delete the coordinate. - return handle_a_tag_deletion(tenant, event, state).await; - } - - for target_id in target_ids { - let target_event = match state - .db - .get_event_by_id_including_deleted_for_event_write(tenant.community(), &target_id) - .await? - { - Some(target) => target, - None => continue, - }; - if u32::from(target_event.event.kind.as_u16()) == super::push_lease::KIND_PUSH_LEASE { + channel_id: Option, + thread_meta: Option>, +) -> Result<(StoredEvent, bool), buzz_db::DbError> { + let a_value = event + .tags + .iter() + .find(|t| t.kind().to_string() == "a") + .and_then(|t| t.content()) + .unwrap_or_default(); + let parts: Vec<&str> = a_value.splitn(3, ':').collect(); + let coordinate = match parts.as_slice() { + [kind, pubkey_hex, d_tag] => kind.parse::().ok().map(|k| (k, *pubkey_hex, *d_tag)), + _ => None, + }; + let deleted_coordinate = match coordinate { + // Workflow deletion belongs to the atomic persistence path in ingest. + Some((buzz_core::kind::KIND_WORKFLOW_DEF, ..)) => { + return Err(buzz_db::DbError::InvalidData( + "workflow deletion requires atomic persistence".into(), + )); + } + // kind:30350 revocation is exclusively a higher-generation inactive replacement. + Some((kind, _, d_tag)) if kind == super::push_lease::KIND_PUSH_LEASE => { + tracing::debug!(d_tag, "NIP-09 deletion ignored for push lease"); + None + } + Some((kind, pubkey_hex, d_tag)) if is_parameterized_replaceable(kind) => { + Some((kind, pubkey_hex, d_tag)) + } + _ => { tracing::debug!( - target_id = %hex::encode(&target_id), - "NIP-09 deletion ignored for push lease" + a_value, + "NIP-09 a-tag deletion for non-NIP-33 kind — no side effect" ); - continue; + None } - - let meta = state - .db - .get_thread_metadata_by_event(tenant.community(), &target_id) - .await?; - let parent_id = meta.as_ref().and_then(|m| m.parent_event_id.clone()); - let root_id = meta.as_ref().and_then(|m| m.root_event_id.clone()); - - let deleted = state + }; + let Some((kind, pubkey_hex, d_tag)) = deleted_coordinate else { + return state .db - .soft_delete_event_and_update_thread( + .insert_event_with_thread_metadata( tenant.community(), - &target_id, - parent_id.as_deref(), - root_id.as_deref(), + event, + channel_id, + thread_meta, + buzz_db::ArchiveGate::Refuse, ) - .await?; + .await; + }; + let pubkey = hex::decode(pubkey_hex).map_err(|e| { + buzz_db::DbError::InvalidData(format!("invalid pubkey hex in a-tag {pubkey_hex}: {e}")) + })?; + // NIP-09 scopes an a-tag deletion to versions at or before the + // deletion's own created_at, so a stale/replayed tombstone can never + // erase a newer replacement head. Safe cast: NIP-33 kinds are + // 30000–39999, well within i32. + let write = state + .db + .insert_coordinate_deletion( + tenant.community(), + event, + channel_id, + thread_meta, + kind as i32, + &pubkey, + d_tag, + buzz_db::ArchiveGate::Refuse, + ) + .await?; + tracing::debug!( + kind, + d_tag, + deleted = write.deleted, + "NIP-09 a-tag deletion by coordinate" + ); + Ok((write.stored, write.inserted)) +} - if !deleted { - continue; - } +/// The key of the reaction row a kind:7 `reaction` created, read from the +/// reaction and its target, for a row that does not carry the reaction's id. +struct ReactionRow { + event_id: Vec, + event_created_at: chrono::DateTime, + pubkey: Vec, + emoji: String, +} - // Thread counters were decremented in the same transaction — push a - // fresh relay-signed 39005 so live badge counts also count *down*. - if let (Some(root_id), Some(channel_id)) = (root_id, target_event.channel_id) { - emit_live_thread_summary(tenant, state, channel_id, root_id); - } - - if u32::from(target_event.event.kind.as_u16()) == KIND_REACTION { - // Try by reaction_event_id first; fall back to tuple-based removal - // if the backfill was missed (set_reaction_event_id is best-effort). - let removed = state - .db - .remove_reaction_by_source_event_id(tenant.community(), &target_id) - .await - .unwrap_or(false); - if !removed { - // Derive (target, actor, emoji) from the reaction event itself. - // Use effective_message_author to handle legacy relay-signed - // reactions where event.pubkey is the relay key, not the user. - let actor = super::ingest::effective_message_author( - &target_event.event, - &state.relay_keypair.public_key(), - ); - let emoji = if target_event.event.content.is_empty() { - "+" - } else { - &target_event.event.content - }; - if let Some(react_target_hex) = target_event.event.tags.iter().rev().find_map(|t| { - if t.kind().to_string() == "e" { - t.content().and_then(|v| { - if v.len() == 64 && v.chars().all(|c| c.is_ascii_hexdigit()) { - Some(v.to_string()) - } else { - None - } - }) - } else { - None - } - }) { - if let Ok(react_target_id) = hex::decode(&react_target_hex) { - if let Ok(Some(react_target_event)) = state - .db - .get_event_by_id_for_event_write(tenant.community(), &react_target_id) - .await - { - let react_target_ts = chrono::DateTime::from_timestamp( - react_target_event.event.created_at.as_secs() as i64, - 0, - ) - .unwrap_or_else(chrono::Utc::now); - if let Err(e) = state - .db - .remove_reaction( - tenant.community(), - &react_target_id, - react_target_ts, - &actor, - emoji, - ) - .await - { - tracing::warn!( - error = %e, - "failed to remove reaction from DB during NIP-09 deletion" - ); - } - } - } - } - } +impl ReactionRow { + fn key(&self) -> buzz_db::event_deletion::ReactionRowKey<'_> { + buzz_db::event_deletion::ReactionRowKey { + event_id: &self.event_id, + event_created_at: self.event_created_at, + pubkey: &self.pubkey, + emoji: &self.emoji, } } +} - Ok(()) +/// The [`ReactionRow`] of `reaction`; `None` when its target is missing. +async fn reaction_row_of( + tenant: &TenantContext, + state: &Arc, + reaction: &Event, +) -> Result, buzz_db::DbError> { + // Use effective_message_author to handle legacy relay-signed reactions + // where event.pubkey is the relay key, not the user. + let pubkey = + super::ingest::effective_message_author(reaction, &state.relay_keypair.public_key()); + let emoji = if reaction.content.is_empty() { + "+".to_string() + } else { + reaction.content.clone() + }; + let Some(event_id) = reaction.tags.iter().rev().find_map(|t| { + if t.kind().to_string() != "e" { + return None; + } + t.content() + .filter(|v| v.len() == 64 && v.chars().all(|c| c.is_ascii_hexdigit())) + .and_then(|v| hex::decode(v).ok()) + }) else { + return Ok(None); + }; + let Some(target) = state + .db + .get_event_by_id_for_event_write(tenant.community(), &event_id) + .await? + else { + return Ok(None); + }; + let event_created_at = + chrono::DateTime::from_timestamp(target.event.created_at.as_secs() as i64, 0) + .unwrap_or_else(chrono::Utc::now); + Ok(Some(ReactionRow { + event_id, + event_created_at, + pubkey, + emoji, + })) } /// Most members a kind:9007 may add besides its creator. @@ -4167,7 +4277,12 @@ async fn publish_nip43_delta( let (stored, was_inserted) = state .db - .insert_event(tenant.community(), &event, None) + .insert_event( + tenant.community(), + &event, + None, + buzz_db::ArchiveGate::Exempt, + ) .await?; if !was_inserted { return Ok(()); @@ -4452,7 +4567,12 @@ pub async fn publish_nipia_archival_list( let (stored, was_inserted) = state .db - .replace_addressable_event(tenant.community(), &event, None) + .replace_addressable_event( + tenant.community(), + &event, + None, + buzz_db::ArchiveGate::Exempt, + ) .await?; if !was_inserted { continue; @@ -4557,7 +4677,14 @@ pub async fn publish_dm_visibility_snapshot( let (stored, was_inserted) = state .db - .replace_parameterized_event(tenant.community(), &event, &viewer_hex, None) + .replace_parameterized_event( + tenant.community(), + &event, + &viewer_hex, + None, + // The viewer's own DM view state (Q2). + buzz_db::ArchiveGate::Exempt, + ) .await?; if was_inserted { dispatch_persistent_event( @@ -4624,7 +4751,12 @@ async fn publish_nipia_delta( let (stored, was_inserted) = state .db - .insert_event(tenant.community(), &event, None) + .insert_event( + tenant.community(), + &event, + None, + buzz_db::ArchiveGate::Exempt, + ) .await?; if !was_inserted { return Ok(()); diff --git a/crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs b/crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs index 710fbdbe90b..6acc2ad0459 100644 --- a/crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs @@ -947,7 +947,12 @@ async fn membership_notices_reach_only_filters_that_know_their_type() { .expect("sign"); f.state .db - .insert_event(f.tenant.community(), &legacy, None) + .insert_event( + f.tenant.community(), + &legacy, + None, + buzz_db::ArchiveGate::Exempt, + ) .await .expect("store legacy notice"); let legacy_id = legacy.id.to_hex(); diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 59803a0aa83..aaf989ed45f 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -1219,7 +1219,15 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { loop { tokio::time::sleep(std::time::Duration::from_secs(reaper_interval_secs)).await; - let expired = match reaper_state.db.reap_expired_ephemeral_channels().await { + // One reaper transaction holds at most this many channel gates, + // the same bound as one guarded lifecycle request. + let expired = match reaper_state + .db + .reap_expired_ephemeral_channels( + reaper_state.config.lifecycle_max_subtree_channels, + ) + .await + { Ok(ids) => ids, Err(e) => { error!("Ephemeral reaper tick failed: {e}"); diff --git a/crates/buzz-relay/src/nip_fi_test_hooks.rs b/crates/buzz-relay/src/nip_fi_test_hooks.rs index b37209b451d..9bcbd3ec439 100644 --- a/crates/buzz-relay/src/nip_fi_test_hooks.rs +++ b/crates/buzz-relay/src/nip_fi_test_hooks.rs @@ -226,8 +226,9 @@ make_hook!(audio_participant_commit_hook, before_participant_commit); // `before_audio_refusal_frames`: fires in `audio/handler.rs` once a NIP-42 // failure or relay-membership refusal is decided, before its frames are // written. `before_join_refusal_rollback`: fires in `commit_participant_join` -// once the early archived check refuses, before `tx.rollback()`. A test holds -// either window to prove the shadow session already retired. +// once an archived check refuses (the write gate check or the re-check under +// the channel row lock), before `tx.rollback()`. A test holds either window to +// prove the shadow session already retired. make_hook!(audio_refusal_frames_hook, before_audio_refusal_frames); make_hook!(join_refusal_rollback_hook, before_join_refusal_rollback); make_hook!(audio_participant_fanout_hook, after_participant_fanout); diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 9c80916cf66..f160c1bb3b9 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -446,9 +446,16 @@ impl ActionSink for RelayActionSink { &event, Some(channel_uuid), thread_meta, + buzz_db::ArchiveGate::Refuse, ) .await - .map_err(|e| ActionSinkError::Database(e.to_string()))?; + .map_err(|e| { + // The channel was archived after the check above. + if crate::handlers::ingest::is_archived_refusal(&e) { + return ActionSinkError::ChannelArchived(channel_id_canonical.clone()); + } + ActionSinkError::Database(e.to_string()) + })?; // 5. Post-persist side effects (fan-out, search, audit) // Only if actually inserted (idempotency guard). @@ -862,7 +869,13 @@ mod postgres_tests { let trigger_ctx_json = serde_json::to_value(&trigger_ctx).expect("serialize trigger"); let run_id = state .db - .create_workflow_run(community, workflow_id, None, Some(&trigger_ctx_json)) + .create_workflow_run( + community, + workflow_id, + None, + Some(&trigger_ctx_json), + buzz_db::ArchiveGate::Exempt, + ) .await .expect("create workflow run"); @@ -1222,7 +1235,12 @@ mod postgres_tests { let root_hex = root_event.id.to_hex(); state .db - .insert_event(community, &root_event, Some(channel.id)) + .insert_event( + community, + &root_event, + Some(channel.id), + buzz_db::ArchiveGate::Exempt, + ) .await .expect("insert root"); @@ -1240,7 +1258,12 @@ mod postgres_tests { let parent_hex = parent_event.id.to_hex(); state .db - .insert_event(community, &parent_event, Some(channel.id)) + .insert_event( + community, + &parent_event, + Some(channel.id), + buzz_db::ArchiveGate::Exempt, + ) .await .expect("insert parent"); assert!( @@ -1332,7 +1355,12 @@ mod postgres_tests { let root_only_parent_bytes = root_only_parent.id.as_bytes().to_vec(); state .db - .insert_event(community, &root_only_parent, Some(channel.id)) + .insert_event( + community, + &root_only_parent, + Some(channel.id), + buzz_db::ArchiveGate::Exempt, + ) .await .expect("insert root-only parent"); @@ -1469,7 +1497,12 @@ mod postgres_tests { async move { state .db - .insert_event(community, &event, Some(channel_id)) + .insert_event( + community, + &event, + Some(channel_id), + buzz_db::ArchiveGate::Exempt, + ) .await .expect("insert parent"); event.id.to_hex() diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index 36a9bd9ea9b..539e03f57e6 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -47,7 +47,7 @@ use std::sync::OnceLock; use buzz_core::kind::{event_kind_u32, is_workflow_execution_kind, KIND_REACTION}; use buzz_core::tenant::CommunityId; use buzz_db::workflow::RunStatus; -use buzz_db::Db; +use buzz_db::{ArchiveGate, Db}; use chrono::{DateTime, Utc}; use dashmap::DashMap; use tokio::sync::Semaphore; @@ -459,10 +459,15 @@ impl WorkflowEngine { workflow.id, Some(&trigger_event_id_bytes), Some(&trigger_ctx_json), + ArchiveGate::Refuse, ) .await { Ok(id) => id, + Err(e) if is_admission_refusal(&e) => { + tracing::debug!(workflow_id = %workflow.id, "Run refused: {e}"); + continue; + } Err(e) => { tracing::error!(workflow_id = %workflow.id, "Failed to create run: {e}"); continue; @@ -675,10 +680,24 @@ impl WorkflowEngine { // community claims independently. match self .db - .claim_scheduled_workflow_fire(community_id, workflow.id, scheduled_for) + .claim_scheduled_workflow_fire( + community_id, + workflow.id, + scheduled_for, + ArchiveGate::Refuse, + ) .await { Ok(Some(_)) => {} + Err(e) if is_admission_refusal(&e) => { + // Nothing is claimed, so the instant is not used up while + // the channel is archived. Advance the clock as below. + tracing::debug!(workflow_id = %workflow.id, "Cron tick: claim refused: {e}"); + if trigger_type == "interval" { + self.last_fired.insert((community_id, workflow.id), now); + } + continue; + } Ok(None) => { // Another pod (or an earlier tick this pod) already // claimed this instant. Still advance the in-memory @@ -723,10 +742,16 @@ impl WorkflowEngine { workflow.id, None, // no trigger event for cron trigger_ctx_json.as_ref(), + ArchiveGate::Refuse, ) .await { Ok(id) => id, + // The claim keeps its NULL run, as below. + Err(e) if is_admission_refusal(&e) => { + tracing::debug!(workflow_id = %workflow.id, "Cron tick: run refused: {e}"); + continue; + } Err(e) => { tracing::error!( workflow_id = %workflow.id, @@ -860,6 +885,18 @@ fn cron_fire_instant( } } +/// Whether run admission refused `error`: the workflow's channel is archived, +/// the workflow has no channel, or its binding changed under the gate. +fn is_admission_refusal(error: &buzz_db::DbError) -> bool { + match error { + buzz_db::DbError::AccessDenied(reason) => { + reason == buzz_db::lifecycle::CHANNEL_ARCHIVED + || reason == buzz_db::workflow::WORKFLOW_UNBOUND + } + error => matches!(error, buzz_db::DbError::LifecycleConflict), + } +} + /// Quantize `now` to the interval bucket boundary, yielding a deterministic /// claim anchor that every pod computes identically within the same bucket. /// @@ -2485,6 +2522,14 @@ steps: .await .expect("on_event with healthy lookup"); assert_eq!(run_count().await, 1, "a readable, unbanned owner fires"); + db.archive_channel(community, channel_id) + .await + .expect("archive the channel"); + engine + .on_event(community, &message_event(channel_id)) + .await + .expect("on_event in an archived channel"); + assert_eq!(run_count().await, 1, "an archived channel refuses the run"); failing_pool.close().await; sqlx::raw_sql(sqlx::AssertSqlSafe(format!("DROP SCHEMA {schema} CASCADE"))) @@ -2492,4 +2537,200 @@ steps: .await .expect("drop failing restriction schema"); } + + fn interval_def(name: &str) -> String { + serde_json::json!({ + "name": name, + "trigger": {"on": "schedule", "interval": "1h"}, + "steps": [{"id": "s1", "action": "send_message", "text": "tick"}], + "enabled": true, + }) + .to_string() + } + + /// Poll the boolean `sql`, with `$1 = key`, until it is true. + async fn until(db: &buzz_db::Db, what: &str, sql: &'static str, key: &str) { + let reached = tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + let done: bool = sqlx::query_scalar(sql) + .bind(key) + .fetch_one(db.pool()) + .await + .expect("inspect locks"); + if done { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await; + assert!(reached.is_ok(), "never reached: {what}"); + } + + /// An archived channel refuses a schedule's claim, so no instant is used + /// up and the anchor does not move: a cron schedule claims nothing while + /// archived and fires again after the restore. An interval schedule's + /// refused claim advances only its in-memory clock, as a lost claim does, + /// so it fires once, within one interval of the restore. Mutations: claim + /// under Exempt and the archived tick claims; drop the clock advance and + /// the interval fires on the first tick after the restore. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn cron_tick_claims_nothing_in_an_archived_channel() { + let db = setup_db().await; + let creator = nostr::Keys::generate().public_key().to_bytes().to_vec(); + let member = nostr::Keys::generate().public_key().to_bytes().to_vec(); + let (community, channel) = setup_channel(&db, &creator, &member).await; + let mut workflows = Vec::new(); + for (name, def, secret) in [ + ("cron-archived", schedule_def("cron-archived"), [4u8; 32]), + ( + "interval-archived", + interval_def("interval-archived"), + [5u8; 32], + ), + ] { + let created = + db.create_workflow(community, Some(channel), &creator, name, &def, &secret); + workflows.push(created.await.expect("create workflow")); + } + let [cron, interval] = <[Uuid; 2]>::try_from(workflows).expect("two workflows"); + let engine = Arc::new(WorkflowEngine::new(db.clone(), WorkflowConfig::default())); + let runs = |id| { + let db = db.clone(); + async move { + db.list_workflow_runs(community, id, 10) + .await + .expect("runs") + .len() + } + }; + let anchor = |id| { + let db = db.clone(); + async move { + db.latest_scheduled_workflow_fire(community, id) + .await + .expect("anchor") + } + }; + let hour = chrono::Duration::hours(1); + let minute = chrono::Duration::minutes(1); + + // Cron fires; the interval seeds its clock. + let start = Utc::now(); + engine.cron_tick(start).await; + let fired = anchor(cron).await; + assert!(fired.is_some()); + + assert!(db + .archive_channel(community, channel) + .await + .expect("archive")); + engine.cron_tick(start + hour).await; + assert_eq!( + anchor(cron).await, + fired, + "the archived tick claims nothing" + ); + assert_eq!(anchor(interval).await, None); + assert_eq!((runs(cron).await, runs(interval).await), (1, 0)); + let clock = engine.last_fired.get(&(community, interval)).map(|t| *t); + assert_eq!(clock, Some(start + hour), "the refusal advances the clock"); + + db.unarchive_channel(community, channel) + .await + .expect("restore"); + engine.cron_tick(start + hour + minute).await; + assert_eq!((runs(cron).await, runs(interval).await), (2, 0)); + engine.cron_tick(start + hour * 2).await; + engine.cron_tick(start + hour * 2 + minute).await; + assert_eq!( + runs(interval).await, + 1, + "once, within one interval of the restore" + ); + } + + /// An archive that commits between a won claim and its run refuses the + /// run, and the claim keeps its NULL run, so the instant never fires + /// twice. A holder stops the claim at its insert, after its gate; the + /// archive queues on the gate, so it commits before the run takes the + /// gate. Mutation: create the cron run under Exempt and the run is created. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn an_archive_between_claim_and_run_leaves_the_orphan_claim() { + let db = setup_db().await; + let creator = nostr::Keys::generate().public_key().to_bytes().to_vec(); + let member = nostr::Keys::generate().public_key().to_bytes().to_vec(); + let (community, channel) = setup_channel(&db, &creator, &member).await; + let def = schedule_def("cron-orphan"); + let workflow = db.create_workflow( + community, + Some(channel), + &creator, + "cron-orphan", + &def, + &[6; 32], + ); + let workflow = workflow.await.expect("create workflow"); + let engine = Arc::new(WorkflowEngine::new(db.clone(), WorkflowConfig::default())); + + let mut holder = db.pool().begin().await.expect("begin holder"); + sqlx::query("LOCK TABLE scheduled_workflow_fires IN SHARE MODE") + .execute(&mut *holder) + .await + .expect("pause claims"); + let tick = { + let engine = Arc::clone(&engine); + tokio::spawn(async move { engine.cron_tick(Utc::now()).await }) + }; + until( + &db, + "the claim waits at its insert", + "SELECT EXISTS (SELECT 1 FROM pg_locks \ + WHERE locktype = 'relation' AND NOT granted AND relation = $1::regclass)", + "scheduled_workflow_fires", + ) + .await; + let archive = { + let db = db.clone(); + tokio::spawn(async move { db.archive_channel(community, channel).await }) + }; + until( + &db, + "the archive queues on the gate", + "SELECT EXISTS (SELECT 1 FROM pg_locks \ + WHERE locktype = 'advisory' AND NOT granted AND mode = 'ExclusiveLock' \ + AND objsubid = 1 \ + AND ((classid::bigint << 32) | objid::bigint) = hashtextextended($1, 0))", + &format!("buzz_channel_gate:{}:{channel}", community.as_uuid()), + ) + .await; + holder.commit().await.expect("release the claims"); + + let wait = std::time::Duration::from_secs(10); + tokio::time::timeout(wait, tick) + .await + .expect("the tick returns") + .expect("tick"); + let archived = tokio::time::timeout(wait, archive) + .await + .expect("the archive returns"); + assert!(archived.expect("archive task").expect("archive")); + let claim: Option> = sqlx::query_scalar( + "SELECT workflow_run_id FROM scheduled_workflow_fires \ + WHERE community_id = $1 AND workflow_id = $2", + ) + .bind(community.as_uuid()) + .bind(workflow) + .fetch_optional(db.pool()) + .await + .expect("read the claim"); + assert_eq!(claim, Some(None), "the claim stays, with no run"); + let runs = db + .list_workflow_runs(community, workflow, 10) + .await + .expect("runs"); + assert!(runs.is_empty(), "the archived channel refuses the run"); + } }