mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
## Summary Phase 1 of retiring the channel roster snapshot trigger (`trg_events_guard_channel_roster_snapshot`, migration 0032). Every admitted writer that can store a kind 39002 roster snapshot now runs the same check the trigger runs, on the same transaction and under the same lock: - `LockedMemberSnapshot::replace_member_event` (relay's live roster publisher) validates the snapshot it already holds the membership lock for, before soft-deleting the old head. - `insert_event_on` and `insert_event_with_thread_metadata_tx` (generic event writes). - `replace_parameterized_event_in_transaction_impl` (inside its existing savepoint) and `Db::replace_addressable_event` (admin reconciliation path), after dominance checks and before the old head is deleted. The validator (`validate_channel_roster_snapshot`) is a direct port of `guard_channel_roster_snapshot()`: four-field `p` tags, 64-char hex pubkey, empty relay field, valid role, and an exact match against active `channel_members` rows. It raises the same `23514` messages, so callers still see `DbError::Sqlx(sqlx::Error::Database(..))` with the same code and text. **Lock order:** replacement key, then channel-membership key, then the canonical read, then the old-head `UPDATE` and the write. Legacy writers took the membership key later, at the trigger's INSERT, after the old-head row locks. Taking it before the `UPDATE` is not the same order, but it is strictly safer: it removes the row-lock/membership-key cycle. The membership writers in `channel_members.rs` take the same membership key before they change rows. Not every membership writer does yet: `execute_kick_with_marker` (reached from report enforcement) and `deploy_kick_member` in `relay_admin_actions.rs` set `removed_at` without the key, so a kick can commit after a roster passes validation and before that roster commits, leaving the removed member listed. The retained trigger has the same gap, so this PR neither causes nor closes it. When the trigger also runs it re-takes a key this transaction already holds; transaction advisory locks are re-entrant, so this adds no wait. The validator is one verdict query on the happy path. On failure it raises the trigger's exact error via a `DO` block, so mixed-version behavior is identical; phase 2 can switch to a typed `DbError`. **Unchanged on purpose:** the trigger, its function, migration 0032, and the startup probe `verify_channel_roster_fence_behavior`. Pods still on the old version depend on them. Phase 2 drops them once this version is running everywhere. ## Tests New PG tests in `channel_members.rs` `postgres_tests`, each with only the roster trigger disabled (the helper asserts the catalog check then fails): - `app_roster_guard_matches_trigger_for_roster_input_table`: 19 shape and membership cases (3/5-field tags, short and non-hex pubkeys, relay field, unknown role, duplicate, role mismatch, missing/extra member, uppercase hex, non-`p` tags, empty rosters, all roles). For each one, the app outcome through `Db::replace_addressable_event` must equal the trigger's outcome on a raw INSERT, including SQLSTATE and message. - `app_roster_guard_locked_writer_rejects_stale_and_accepts_current_without_trigger` - `app_roster_guard_covers_generic_and_parameterized_writers_without_trigger` - `app_roster_guard_rechecks_after_concurrent_membership_commit_without_trigger`: the 0032 race. A newer membership commits while an older snapshot waits on the membership key. The barrier is `pg_locks` with a bound, not sleeps. `stale_legacy_roster_cannot_replace_new_locked_snapshot` (`replaceable.rs`) and `desired_schema_rejects_stale_legacy_roster_role` (`channel_members.rs`) now simulate the old binary inline (replacement key, old-head `UPDATE`, raw INSERT) instead of calling the app API, so they test the retained trigger again. The replaceable.rs one keeps its one-connection pool, so it still catches self-deadlock. `observability_source.rs` gains `every_production_event_insert_runs_the_roster_guard`, which requires a real guard call in every production `INSERT INTO events` function, with explicit exceptions (NIP-43 membership and push lease rows, both `channel_id` NULL, and the two rolled-back startup probes), plus a self-test. Falsified: removing one guard call fails the source policy; dropping the role clause or `lower()` fails the parity table; disabling the trigger fails both legacy tests. The PG tests are picked up by the `postgres-ci` nextest filter (`test(/postgres_tests::/)`); the source policy runs in `just test-unit`. ### Coverage limits (review follow-up) - `every_production_event_insert_runs_the_roster_guard` is a presence check. It rejects missing, commented, and quoted calls, but it does not prove a call is awaited, reachable, or passed the event's real kind and channel. Behavior is covered by the PG tests above. The test now says this in its doc comment. - Exemptions match by function header. A kind 39002 insert added inside one of the four exempt functions would pass the policy. This is documented next to the exemption list. - The 19-case parity table and the race test drive `Db::replace_addressable_event` only. The generic, parameterized, and locked writers get the mismatch case only, and the parameterized path's membership-key-before-delete ordering is not exercised concurrently. All five writers call the same validator, so this PR doesn't widen these tests. - `disable_roster_trigger` commits `DISABLE TRIGGER` on the test database. That is safe under the per-test database harness the `postgres-ci` profile uses, but not against a shared database. Its doc comment now says so. ## Validation Run on a Blox workstation (private PG 16 and Redis) against a tree byte-identical to this commit: - `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo nextest run -p buzz-db --lib`: pass. - `cargo nextest run -p buzz-db --test observability_source`: 19 passed. - `scripts/postgres-test-run.sh -p <pkg>` for buzz-admin, buzz-audit, buzz-deletion, buzz-push-gateway, buzz-relay, buzz-search, buzz-workflow: pass. - buzz-db: the full run passed 513 and was stopped at a 240 s deadline on `writer_pool_rejects_non_read_committed_database_default`, which hangs the same way on the unmodified base. A second run excluding the three host-flaky runtime tests already reproduced on the base passed all 511 others. ## Simpler? Not yet. While the trigger is still in place, the check runs twice. Phase 2 deletes the trigger, its function, the startup catalog and behavior probes, the migration.rs equivalence asserts, and the pgschema reconcile entry, which leaves one check in Rust. ## Phase 2 (separate PR, after fleet rollout) **Prerequisite before claiming complete roster serialization (phase 2 or anywhere else):** make both kick paths take the channel membership key before they change rows, and add a concurrent kick-vs-roster-publication test. The retained trigger does not prove this; it has the same gap. A new migration drops the trigger from `events` and its partitions, then drops `guard_channel_roster_snapshot()`. It also removes the schema files and includes, `verify_channel_roster_fence*` and the relay startup call, the migration.rs asserts, and the probe tests, and updates `scripts/reconcile-schema-after-pgschema.sql`. --------- Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2429 lines
93 KiB
Rust
2429 lines
93 KiB
Rust
//! Replaceable-event persistence and coordinate locking.
|
||
|
||
use buzz_core::{CommunityId, StoredEvent};
|
||
use buzz_datastore_tracing::datastore_span;
|
||
use chrono::{DateTime, Utc};
|
||
use uuid::Uuid;
|
||
|
||
use crate::observability::{self, LockType, TransactionOperation};
|
||
use crate::AdmittedTx;
|
||
use crate::{Db, DbError, Result};
|
||
|
||
/// Result category for a parameterized-replaceable event write.
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
pub enum ParameterizedReplaceStatus {
|
||
/// The incoming event was inserted as the coordinate's live head.
|
||
Inserted,
|
||
/// The exact event was already accepted.
|
||
Duplicate,
|
||
/// A newer event, or lower-ID same-second event, already dominates it.
|
||
Superseded,
|
||
/// A requested current revision has no live coordinate head.
|
||
RevisionMissing,
|
||
/// The live coordinate head differs from the requested revision.
|
||
RevisionMismatch,
|
||
/// An exact replay was required, but the event is not the live head.
|
||
ReplayOnlyMiss,
|
||
}
|
||
|
||
/// Structural precondition for a parameterized-replaceable write.
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
pub enum ParameterizedReplacePrecondition<'a> {
|
||
/// Apply normal NIP-33 ordering without a revision precondition.
|
||
Unconditional,
|
||
/// Require the live head to match this validated event ID.
|
||
ExpectedRevision(&'a [u8]),
|
||
/// Accept only an exact live-head replay and perform no mutation otherwise.
|
||
ExactReplayOnly,
|
||
}
|
||
|
||
/// Result of a transaction-bound parameterized-replaceable event write.
|
||
#[derive(Clone, Debug)]
|
||
pub struct ParameterizedReplaceResult {
|
||
/// Stored representation of the submitted event.
|
||
pub event: StoredEvent,
|
||
/// Whether and why the coordinate accepted the event.
|
||
pub status: ParameterizedReplaceStatus,
|
||
}
|
||
|
||
impl ParameterizedReplaceResult {
|
||
fn new(
|
||
event: &nostr::Event,
|
||
received_at: DateTime<Utc>,
|
||
channel_id: Option<Uuid>,
|
||
status: ParameterizedReplaceStatus,
|
||
) -> Self {
|
||
Self {
|
||
event: StoredEvent::with_received_at(
|
||
event.clone(),
|
||
received_at,
|
||
channel_id,
|
||
status == ParameterizedReplaceStatus::Inserted,
|
||
),
|
||
status,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Derive the transaction-scoped advisory-lock key for an event coordinate.
|
||
///
|
||
/// Hash collisions only add serialization; the SQL predicates still determine
|
||
/// which rows are read or changed.
|
||
pub(crate) fn event_replacement_lock_key(
|
||
community_id: CommunityId,
|
||
kind: i32,
|
||
pubkey: &[u8],
|
||
coordinate: Option<&[u8]>,
|
||
) -> i64 {
|
||
let mut hash: u64 = 0xcbf29ce484222325;
|
||
let kind_bytes = kind.to_le_bytes();
|
||
for bytes in [
|
||
community_id.as_uuid().as_bytes().as_slice(),
|
||
kind_bytes.as_slice(),
|
||
pubkey,
|
||
] {
|
||
for byte in bytes {
|
||
hash ^= *byte as u64;
|
||
hash = hash.wrapping_mul(0x100000001b3);
|
||
}
|
||
}
|
||
if let Some(coordinate) = coordinate {
|
||
for byte in coordinate {
|
||
hash ^= *byte as u64;
|
||
hash = hash.wrapping_mul(0x100000001b3);
|
||
}
|
||
}
|
||
hash as i64
|
||
}
|
||
|
||
/// 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.
|
||
async fn replace_parameterized_event_in_transaction_impl(
|
||
tx: &mut AdmittedTx,
|
||
event: &nostr::Event,
|
||
d_tag: &str,
|
||
channel_id: Option<Uuid>,
|
||
precondition: ParameterizedReplacePrecondition<'_>,
|
||
) -> Result<ParameterizedReplaceResult> {
|
||
let community_id = tx.community();
|
||
let kind_i32 = buzz_core::kind::event_kind_i32(event);
|
||
let pubkey_bytes = event.pubkey.to_bytes();
|
||
let created_at_secs = event.created_at.as_secs() as i64;
|
||
let created_at = DateTime::from_timestamp(created_at_secs, 0)
|
||
.ok_or(DbError::InvalidTimestamp(created_at_secs))?;
|
||
let received_at = Utc::now();
|
||
|
||
let lock_key = event_replacement_lock_key(
|
||
community_id,
|
||
kind_i32,
|
||
pubkey_bytes.as_slice(),
|
||
Some(d_tag.as_bytes()),
|
||
);
|
||
observability::observe_advisory_lock(
|
||
LockType::Replacement,
|
||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||
.bind(lock_key)
|
||
.execute(tx.conn()),
|
||
)
|
||
.await?;
|
||
|
||
let d_tag_count = event
|
||
.tags
|
||
.iter()
|
||
.filter(|tag| tag.as_slice().first().is_some_and(|part| part == "d"))
|
||
.count();
|
||
let has_exact_d_tag = event.tags.iter().any(|tag| {
|
||
let parts = tag.as_slice();
|
||
parts.len() >= 2 && parts[0] == "d" && parts[1] == d_tag
|
||
});
|
||
let read_state_t_tag_count = event
|
||
.tags
|
||
.iter()
|
||
.filter(|tag| {
|
||
let parts = tag.as_slice();
|
||
parts.len() == 2 && parts[0] == "t" && parts[1] == "read-state"
|
||
})
|
||
.count();
|
||
let is_nip_rs = kind_i32 == buzz_core::kind::KIND_READ_STATE as i32
|
||
&& d_tag_count == 1
|
||
&& has_exact_d_tag
|
||
&& d_tag.strip_prefix("read-state:").is_some_and(|slot| {
|
||
slot.len() == 32
|
||
&& slot
|
||
.bytes()
|
||
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
|
||
})
|
||
&& read_state_t_tag_count == 1;
|
||
let is_buzz_mesh_status = kind_i32 == buzz_core::kind::KIND_BOOKMARK_SET as i32
|
||
&& d_tag.starts_with("buzz-mesh-member-status:")
|
||
&& event.tags.iter().any(|tag| {
|
||
let parts = tag.as_slice();
|
||
parts.len() == 2 && parts[0] == "k" && parts[1] == "buzz-mesh-status"
|
||
});
|
||
let hard_delete_superseded = is_nip_rs || is_buzz_mesh_status;
|
||
|
||
let existing: Option<(DateTime<Utc>, Vec<u8>)> = sqlx::query_as(
|
||
"SELECT created_at, id FROM events \
|
||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL \
|
||
ORDER BY created_at DESC, id ASC LIMIT 1",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind_i32)
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(d_tag)
|
||
.fetch_optional(tx.conn())
|
||
.await?;
|
||
let watermark: Option<(DateTime<Utc>, Vec<u8>)> = if is_nip_rs {
|
||
sqlx::query_as(
|
||
"SELECT created_at, event_id FROM parameterized_event_watermarks \
|
||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind_i32)
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(d_tag)
|
||
.fetch_optional(tx.conn())
|
||
.await?
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let incoming_id = event.id.as_bytes().as_slice();
|
||
if existing
|
||
.as_ref()
|
||
.is_some_and(|(_, existing_id)| existing_id.as_slice() == incoming_id)
|
||
|| watermark
|
||
.as_ref()
|
||
.is_some_and(|(_, event_id)| event_id.as_slice() == incoming_id)
|
||
{
|
||
return Ok(ParameterizedReplaceResult::new(
|
||
event,
|
||
received_at,
|
||
channel_id,
|
||
ParameterizedReplaceStatus::Duplicate,
|
||
));
|
||
}
|
||
|
||
if precondition == ParameterizedReplacePrecondition::ExactReplayOnly {
|
||
return Ok(ParameterizedReplaceResult::new(
|
||
event,
|
||
received_at,
|
||
channel_id,
|
||
ParameterizedReplaceStatus::ReplayOnlyMiss,
|
||
));
|
||
}
|
||
|
||
if let ParameterizedReplacePrecondition::ExpectedRevision(expected_revision) = precondition {
|
||
let status = match existing.as_ref() {
|
||
None => Some(ParameterizedReplaceStatus::RevisionMissing),
|
||
Some((_, existing_id)) if existing_id.as_slice() != expected_revision => {
|
||
Some(ParameterizedReplaceStatus::RevisionMismatch)
|
||
}
|
||
Some(_) => None,
|
||
};
|
||
if let Some(status) = status {
|
||
return Ok(ParameterizedReplaceResult::new(
|
||
event,
|
||
received_at,
|
||
channel_id,
|
||
status,
|
||
));
|
||
}
|
||
}
|
||
|
||
let dominated = existing
|
||
.iter()
|
||
.chain(watermark.iter())
|
||
.any(|(accepted_ts, accepted_id)| {
|
||
created_at < *accepted_ts
|
||
|| (created_at == *accepted_ts && incoming_id >= accepted_id.as_slice())
|
||
});
|
||
if dominated {
|
||
return Ok(ParameterizedReplaceResult::new(
|
||
event,
|
||
received_at,
|
||
channel_id,
|
||
ParameterizedReplaceStatus::Superseded,
|
||
));
|
||
}
|
||
|
||
// 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
|
||
// the caller's transaction usable with the previous live head restored.
|
||
sqlx::query("SAVEPOINT parameterized_replace")
|
||
.execute(tx.conn())
|
||
.await?;
|
||
let written: Result<bool> = async {
|
||
let tags_json = serde_json::to_value(&event.tags)?;
|
||
crate::channel_members::guard_channel_roster_event(tx, kind_i32, channel_id, &tags_json)
|
||
.await?;
|
||
if existing.is_some() {
|
||
let previous_nip_rs_hard_delete: Option<String> = if is_nip_rs {
|
||
sqlx::query_scalar(
|
||
"SELECT NULLIF(current_setting('buzz.nip_rs_hard_delete', true), '')",
|
||
)
|
||
.fetch_one(tx.conn())
|
||
.await?
|
||
} else {
|
||
None
|
||
};
|
||
if is_nip_rs {
|
||
sqlx::query("SELECT set_config('buzz.nip_rs_hard_delete', 'on', true)")
|
||
.execute(tx.conn())
|
||
.await?;
|
||
}
|
||
let statement = if hard_delete_superseded {
|
||
"DELETE FROM events \
|
||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL"
|
||
} else {
|
||
"UPDATE events SET deleted_at = NOW() \
|
||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL"
|
||
};
|
||
sqlx::query(statement)
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind_i32)
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(d_tag)
|
||
.execute(tx.conn())
|
||
.await?;
|
||
|
||
if is_nip_rs {
|
||
let previous_value = previous_nip_rs_hard_delete.as_deref().unwrap_or_default();
|
||
sqlx::query("SELECT set_config('buzz.nip_rs_hard_delete', $1, true)")
|
||
.bind(previous_value)
|
||
.execute(tx.conn())
|
||
.await?;
|
||
}
|
||
|
||
if hard_delete_superseded {
|
||
if let Some((_, existing_id)) = &existing {
|
||
sqlx::query("DELETE FROM event_mentions WHERE community_id = $1 AND event_id = $2")
|
||
.bind(community_id.as_uuid())
|
||
.bind(existing_id)
|
||
.execute(tx.conn())
|
||
.await?;
|
||
}
|
||
}
|
||
}
|
||
|
||
let sig_bytes = event.sig.serialize();
|
||
let insert_result = sqlx::query(
|
||
"INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag, not_before) \
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) \
|
||
ON CONFLICT DO NOTHING",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(incoming_id)
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(created_at)
|
||
.bind(kind_i32)
|
||
.bind(&tags_json)
|
||
.bind(&event.content)
|
||
.bind(sig_bytes.as_slice())
|
||
.bind(received_at)
|
||
.bind(channel_id)
|
||
.bind(d_tag)
|
||
.bind(crate::event::extract_not_before(event))
|
||
.execute(tx.conn())
|
||
.await?;
|
||
|
||
if insert_result.rows_affected() == 0 {
|
||
return Ok(false);
|
||
}
|
||
|
||
if is_nip_rs {
|
||
sqlx::query(
|
||
"INSERT INTO parameterized_event_watermarks \
|
||
(community_id, kind, pubkey, d_tag, created_at, event_id) \
|
||
VALUES ($1, $2, $3, $4, $5, $6) \
|
||
ON CONFLICT (community_id, kind, pubkey, d_tag) DO UPDATE SET \
|
||
created_at = EXCLUDED.created_at, event_id = EXCLUDED.event_id",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind_i32)
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(d_tag)
|
||
.bind(created_at)
|
||
.bind(incoming_id)
|
||
.execute(tx.conn())
|
||
.await?;
|
||
}
|
||
|
||
crate::insert_mentions_in_transaction(&mut *tx, event, channel_id).await?;
|
||
Ok(true)
|
||
}
|
||
.await;
|
||
|
||
let status = match written {
|
||
Ok(true) => ParameterizedReplaceStatus::Inserted,
|
||
Ok(false) => {
|
||
sqlx::query("ROLLBACK TO SAVEPOINT parameterized_replace")
|
||
.execute(tx.conn())
|
||
.await?;
|
||
ParameterizedReplaceStatus::Duplicate
|
||
}
|
||
Err(error) => {
|
||
// Restore the caller's transaction; the original error is the
|
||
// one worth reporting if the rollback itself also fails.
|
||
if let Err(rollback_error) = sqlx::query("ROLLBACK TO SAVEPOINT parameterized_replace")
|
||
.execute(tx.conn())
|
||
.await
|
||
{
|
||
tracing::warn!(error = %rollback_error, "parameterized replacement savepoint rollback failed");
|
||
}
|
||
return Err(error);
|
||
}
|
||
};
|
||
sqlx::query("RELEASE SAVEPOINT parameterized_replace")
|
||
.execute(tx.conn())
|
||
.await?;
|
||
if status == ParameterizedReplaceStatus::Inserted {
|
||
crate::store::event_follow_up::after_admitted_insert(tx, incoming_id, kind_i32, channel_id)
|
||
.await?;
|
||
}
|
||
|
||
Ok(ParameterizedReplaceResult::new(
|
||
event,
|
||
received_at,
|
||
channel_id,
|
||
status,
|
||
))
|
||
}
|
||
|
||
impl Db {
|
||
/// Atomically replace a replaceable event: NIP-16 kinds (0, 3, 41, 10000–19999)
|
||
/// and NIP-29 discovery state (39000–39002, called from side_effects.rs).
|
||
///
|
||
/// Keeps only the event with the highest `created_at` per (kind, pubkey, channel_id).
|
||
/// Same-second ties are broken by lowest event `id` (NIP-16 deterministic ordering).
|
||
/// 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")]
|
||
pub async fn replace_addressable_event(
|
||
&self,
|
||
community_id: CommunityId,
|
||
event: &nostr::Event,
|
||
channel_id: Option<Uuid>,
|
||
) -> Result<(StoredEvent, bool)> {
|
||
let kind_i32 = buzz_core::kind::event_kind_i32(event);
|
||
let pubkey_bytes = event.pubkey.to_bytes();
|
||
let created_at_secs = event.created_at.as_secs() as i64;
|
||
let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0)
|
||
.ok_or(DbError::InvalidTimestamp(created_at_secs))?;
|
||
|
||
// Collisions only cause extra serialization; they cannot change behavior.
|
||
let lock_key = event_replacement_lock_key(
|
||
community_id,
|
||
kind_i32,
|
||
pubkey_bytes.as_slice(),
|
||
channel_id.as_ref().map(|id| id.as_bytes().as_slice()),
|
||
);
|
||
|
||
let mut tx = crate::begin_community_event_write_transaction_with_legacy_metrics(
|
||
&self.pool,
|
||
community_id,
|
||
observability::WriterOperation::EventWrite,
|
||
)
|
||
.await?;
|
||
tx.set_push_enqueue(self.push_enqueue.clone());
|
||
let transaction_timer = observability::TransactionTimer::start(
|
||
observability::TransactionOperation::ReplaceAddressableEvent,
|
||
);
|
||
|
||
transaction_timer
|
||
.observe(async {
|
||
// Serialize all writers for the same (kind, pubkey, channel_id) tuple.
|
||
// Advisory lock is transaction-scoped — released on commit/rollback.
|
||
observability::observe_advisory_lock(
|
||
observability::LockType::Replacement,
|
||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||
.bind(lock_key)
|
||
.execute(tx.conn()),
|
||
)
|
||
.await?;
|
||
|
||
// Check for the newest existing event. ORDER BY + LIMIT 1 is defensive against
|
||
// historical data where prior bugs may have left multiple live rows.
|
||
let existing: Option<(chrono::DateTime<chrono::Utc>, Vec<u8>)> =
|
||
sqlx::query_as(
|
||
"SELECT created_at, id FROM events \
|
||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \
|
||
AND channel_id IS NOT DISTINCT FROM $4 \
|
||
AND deleted_at IS NULL \
|
||
ORDER BY created_at DESC, id ASC LIMIT 1",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind_i32)
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(channel_id)
|
||
.fetch_optional(tx.conn())
|
||
.await?;
|
||
|
||
// Stale-write protection: reject if incoming is not newer.
|
||
// NIP-16: created_at is second-resolution. On same-second tie, lowest
|
||
// event id (lexicographic) wins — deterministic across relays.
|
||
let incoming_id = event.id.as_bytes().as_slice();
|
||
if let Some((existing_ts, existing_id)) = existing {
|
||
let dominated = created_at < existing_ts
|
||
|| (created_at == existing_ts
|
||
&& incoming_id >= existing_id.as_slice());
|
||
if dominated {
|
||
tx.rollback().await?;
|
||
let received_at = chrono::Utc::now();
|
||
return Ok((
|
||
StoredEvent::with_received_at(
|
||
event.clone(),
|
||
received_at,
|
||
channel_id,
|
||
false,
|
||
),
|
||
false,
|
||
));
|
||
}
|
||
}
|
||
|
||
let tags_json = serde_json::to_value(&event.tags)?;
|
||
crate::channel_members::guard_channel_roster_event(
|
||
&mut tx, kind_i32, channel_id, &tags_json,
|
||
)
|
||
.await?;
|
||
|
||
// Soft-delete the old event (if any). IS NOT DISTINCT FROM for NULL safety.
|
||
sqlx::query(
|
||
"UPDATE events SET deleted_at = NOW() \
|
||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \
|
||
AND channel_id IS NOT DISTINCT FROM $4 \
|
||
AND deleted_at IS NULL",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(kind_i32)
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(channel_id)
|
||
.execute(tx.conn())
|
||
.await?;
|
||
|
||
// Insert the new event inside the same transaction.
|
||
let sig_bytes = event.sig.serialize();
|
||
let received_at = chrono::Utc::now();
|
||
let d_tag = crate::event::extract_d_tag(event);
|
||
|
||
let insert_result = sqlx::query(
|
||
"INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) \
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
|
||
ON CONFLICT DO NOTHING",
|
||
)
|
||
.bind(community_id.as_uuid())
|
||
.bind(event.id.as_bytes().as_slice())
|
||
.bind(pubkey_bytes.as_slice())
|
||
.bind(created_at)
|
||
.bind(kind_i32)
|
||
.bind(&tags_json)
|
||
.bind(&event.content)
|
||
.bind(sig_bytes.as_slice())
|
||
.bind(received_at)
|
||
.bind(channel_id)
|
||
.bind(d_tag.as_deref())
|
||
.execute(tx.conn())
|
||
.await?;
|
||
|
||
let was_inserted = insert_result.rows_affected() > 0;
|
||
if !was_inserted {
|
||
// ON CONFLICT fired — the event ID already exists. Rollback the
|
||
// soft-delete so we don't lose the previous replaceable event.
|
||
tx.rollback().await?;
|
||
return Ok((
|
||
StoredEvent::with_received_at(
|
||
event.clone(),
|
||
received_at,
|
||
channel_id,
|
||
false,
|
||
),
|
||
false,
|
||
));
|
||
}
|
||
|
||
crate::store::event_follow_up::after_admitted_insert(
|
||
&mut tx,
|
||
event.id.as_bytes().as_slice(),
|
||
kind_i32,
|
||
channel_id,
|
||
)
|
||
.await?;
|
||
|
||
// The replaceable event and its denormalized mention index are one
|
||
// authoritative discovery write. An indexing error must roll back the
|
||
// new event and restore the previously-live event.
|
||
crate::insert_mentions_in_transaction(&mut tx, event, channel_id)
|
||
.await?;
|
||
|
||
tx.commit().await?;
|
||
|
||
Ok((
|
||
StoredEvent::with_received_at(event.clone(), received_at, channel_id, true),
|
||
true,
|
||
))
|
||
})
|
||
.await
|
||
}
|
||
|
||
/// Replace a NIP-33 event inside a caller-owned transaction.
|
||
///
|
||
/// The caller owns commit or rollback. Requiring [`AdmittedTx`] here and
|
||
/// in the internal state machine makes the advisory-lock contract explicit.
|
||
///
|
||
/// On a duplicate or an error the replacement rolls back to its own
|
||
/// 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.
|
||
pub async fn replace_parameterized_event_in_transaction(
|
||
&self,
|
||
tx: &mut AdmittedTx,
|
||
event: &nostr::Event,
|
||
d_tag: &str,
|
||
channel_id: Option<Uuid>,
|
||
precondition: ParameterizedReplacePrecondition<'_>,
|
||
) -> Result<ParameterizedReplaceResult> {
|
||
replace_parameterized_event_in_transaction_impl(tx, event, d_tag, channel_id, precondition)
|
||
.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.
|
||
#[datastore_span(name = "replace_parameterized_event", system = "postgresql")]
|
||
pub async fn replace_parameterized_event(
|
||
&self,
|
||
community_id: CommunityId,
|
||
event: &nostr::Event,
|
||
d_tag: &str,
|
||
channel_id: Option<Uuid>,
|
||
) -> Result<(StoredEvent, bool)> {
|
||
let mut tx = crate::begin_community_event_write_transaction_with_legacy_metrics(
|
||
&self.pool,
|
||
community_id,
|
||
observability::WriterOperation::EventWrite,
|
||
)
|
||
.await?;
|
||
tx.set_push_enqueue(self.push_enqueue.clone());
|
||
let transaction_timer =
|
||
observability::TransactionTimer::start(TransactionOperation::ReplaceParameterizedEvent);
|
||
transaction_timer
|
||
.observe(async {
|
||
let result = self
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
event,
|
||
d_tag,
|
||
channel_id,
|
||
ParameterizedReplacePrecondition::Unconditional,
|
||
)
|
||
.await?;
|
||
let was_inserted = result.status == ParameterizedReplaceStatus::Inserted;
|
||
if was_inserted {
|
||
tx.commit().await?;
|
||
} else {
|
||
tx.rollback().await?;
|
||
}
|
||
Ok((result.event, was_inserted))
|
||
})
|
||
.await
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod postgres_tests {
|
||
use super::*;
|
||
use crate::{event, migration, replaceable};
|
||
use sqlx::postgres::PgPoolOptions;
|
||
use sqlx::{Acquire, PgPool};
|
||
use std::time::Duration;
|
||
|
||
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials
|
||
|
||
async fn setup_db() -> Db {
|
||
let database_url =
|
||
std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into());
|
||
let pool = PgPool::connect(&database_url)
|
||
.await
|
||
.expect("connect to test DB");
|
||
if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() == Ok("migration") {
|
||
migration::run_migrations(&pool)
|
||
.await
|
||
.expect("apply migration schema");
|
||
}
|
||
Db::from_pool(pool)
|
||
}
|
||
|
||
async fn make_community(pool: &PgPool) -> Uuid {
|
||
let id = Uuid::new_v4();
|
||
let host = format!("communities-of-channels-{}.example", id.simple());
|
||
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
|
||
.bind(id)
|
||
.bind(host)
|
||
.execute(pool)
|
||
.await
|
||
.expect("insert community");
|
||
id
|
||
}
|
||
|
||
async fn admin_url() -> String {
|
||
std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into())
|
||
}
|
||
|
||
/// Create a fresh scratch database on the same server and optionally run migrations.
|
||
async fn create_scratch_db_through(
|
||
admin: &PgPool,
|
||
prefix: &str,
|
||
target: Option<i64>,
|
||
) -> (PgPool, String) {
|
||
let name = format!("{}_{}", prefix, Uuid::new_v4().simple());
|
||
sqlx::query(sqlx::AssertSqlSafe(format!("CREATE DATABASE {name}")))
|
||
.execute(admin)
|
||
.await
|
||
.expect("create scratch db");
|
||
let base = admin_url().await;
|
||
// Swap the database path segment of the admin URL for the scratch name.
|
||
let scratch_url = {
|
||
let idx = base.rfind('/').expect("db url has a path segment");
|
||
format!("{}/{}", &base[..idx], name)
|
||
};
|
||
let pool = PgPool::connect(&scratch_url)
|
||
.await
|
||
.expect("connect scratch db");
|
||
match target {
|
||
Some(target) => migration::run_migrations_through(&pool, target)
|
||
.await
|
||
.expect("migrate scratch db through target"),
|
||
None => migration::run_migrations(&pool)
|
||
.await
|
||
.expect("migrate scratch db"),
|
||
}
|
||
(pool, name)
|
||
}
|
||
|
||
/// Create a fresh scratch database on the same server and run all migrations.
|
||
/// Returns (pool, db_name); callers should `drop_scratch_db` when done.
|
||
async fn create_scratch_db(admin: &PgPool, prefix: &str) -> (PgPool, String) {
|
||
create_scratch_db_through(admin, prefix, None).await
|
||
}
|
||
|
||
async fn drop_scratch_db(admin: &PgPool, pool: PgPool, name: &str) {
|
||
pool.close().await;
|
||
let _ = sqlx::query(sqlx::AssertSqlSafe(format!(
|
||
"DROP DATABASE IF EXISTS {name} WITH (FORCE)"
|
||
)))
|
||
.execute(admin)
|
||
.await;
|
||
}
|
||
|
||
/// Insert identical community + channel rows into a database so the same
|
||
/// (community, channel) ids resolve in both writer and replica.
|
||
async fn seed_community_channel(
|
||
pool: &PgPool,
|
||
community: Uuid,
|
||
channel: Uuid,
|
||
author: &nostr::Keys,
|
||
) {
|
||
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
|
||
.bind(community)
|
||
.bind(format!("replica-routing-{}.example", community.simple()))
|
||
.execute(pool)
|
||
.await
|
||
.expect("insert community");
|
||
crate::channel::create_channel_with_id(
|
||
pool,
|
||
CommunityId::from_uuid(community),
|
||
channel,
|
||
&format!("replica-routing-{channel}"),
|
||
crate::channel::ChannelType::Stream,
|
||
crate::channel::ChannelVisibility::Open,
|
||
None,
|
||
author.public_key().to_bytes().as_slice(),
|
||
None,
|
||
&[],
|
||
)
|
||
.await
|
||
.expect("create channel");
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn addressable_replacement_rolls_back_when_mention_indexing_fails() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let admin = PgPool::connect(&admin_url().await)
|
||
.await
|
||
.expect("connect admin");
|
||
let (pool, scratch_name) = create_scratch_db(&admin, "atomic_addressable").await;
|
||
let db = Db::from_pool(pool.clone());
|
||
let community_uuid = Uuid::new_v4();
|
||
let channel = Uuid::new_v4();
|
||
let keys = Keys::generate();
|
||
let owner_keys = Keys::generate();
|
||
seed_community_channel(&pool, community_uuid, channel, &owner_keys).await;
|
||
let community = CommunityId::from_uuid(community_uuid);
|
||
let member = owner_keys.public_key().to_hex();
|
||
let tags = || {
|
||
vec![
|
||
Tag::parse(["d", channel.to_string().as_str()]).expect("d tag"),
|
||
Tag::parse(["p", member.as_str(), "", "owner"]).expect("p tag"),
|
||
]
|
||
};
|
||
let base = Timestamp::now().as_secs();
|
||
let old = EventBuilder::new(Kind::Custom(39002), "old")
|
||
.tags(tags())
|
||
.custom_created_at(Timestamp::from(base))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign old");
|
||
db.replace_addressable_event(community, &old, Some(channel))
|
||
.await
|
||
.expect("insert old roster");
|
||
|
||
sqlx::query(
|
||
"CREATE FUNCTION reject_test_mention() RETURNS trigger AS $$ \
|
||
BEGIN RAISE EXCEPTION 'injected mention failure'; END; \
|
||
$$ LANGUAGE plpgsql",
|
||
)
|
||
.execute(&pool)
|
||
.await
|
||
.expect("create failure function");
|
||
sqlx::query(
|
||
"CREATE TRIGGER reject_test_mention BEFORE INSERT ON event_mentions \
|
||
FOR EACH ROW EXECUTE FUNCTION reject_test_mention()",
|
||
)
|
||
.execute(&pool)
|
||
.await
|
||
.expect("install failure injection");
|
||
|
||
let new = EventBuilder::new(Kind::Custom(39002), "new")
|
||
.tags(tags())
|
||
.custom_created_at(Timestamp::from(base + 1))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign new");
|
||
let error = db
|
||
.replace_addressable_event(community, &new, Some(channel))
|
||
.await
|
||
.expect_err("mention failure must fail replacement");
|
||
assert!(error.to_string().contains("injected mention failure"));
|
||
|
||
let live_id: Vec<u8> = sqlx::query_scalar(
|
||
"SELECT id FROM events WHERE community_id=$1 AND channel_id=$2 \
|
||
AND kind=39002 AND deleted_at IS NULL",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(channel)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("query live roster");
|
||
assert_eq!(live_id, old.id.as_bytes(), "old roster must remain live");
|
||
let new_rows: i64 =
|
||
sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND id=$2")
|
||
.bind(community.as_uuid())
|
||
.bind(new.id.as_bytes().as_slice())
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("count rolled-back event");
|
||
assert_eq!(new_rows, 0, "new roster must roll back with its index");
|
||
|
||
drop_scratch_db(&admin, pool, &scratch_name).await;
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn stale_legacy_roster_cannot_replace_new_locked_snapshot() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let admin = PgPool::connect(&admin_url().await)
|
||
.await
|
||
.expect("connect admin");
|
||
let (setup_pool, scratch_name) = create_scratch_db(&admin, "mixed_roster_writer").await;
|
||
let base_url = admin_url().await;
|
||
let slash = base_url.rfind('/').expect("database URL has path segment");
|
||
let scratch_url = format!("{}/{}", &base_url[..slash], scratch_name);
|
||
let pool = PgPoolOptions::new()
|
||
.max_connections(1)
|
||
.acquire_timeout(Duration::from_secs(1))
|
||
.connect(&scratch_url)
|
||
.await
|
||
.expect("connect one-connection scratch pool");
|
||
setup_pool.close().await;
|
||
let db = Db::from_pool(pool.clone());
|
||
let community_uuid = Uuid::new_v4();
|
||
let community = CommunityId::from_uuid(community_uuid);
|
||
let channel = Uuid::new_v4();
|
||
let relay_keys = Keys::generate();
|
||
let owner_keys = Keys::generate();
|
||
let owner = owner_keys.public_key().to_bytes();
|
||
seed_community_channel(&pool, community_uuid, channel, &owner_keys).await;
|
||
|
||
// This is the old pod's unlocked capture A. It remains in process memory
|
||
// while a role-only canonical mutation advances and the new pod publishes B.
|
||
let base = Timestamp::now().as_secs();
|
||
let roster = |members: &[(&[u8], &str)], timestamp| {
|
||
let tags =
|
||
std::iter::once(Tag::parse(["d", channel.to_string().as_str()]).expect("d tag"))
|
||
.chain(members.iter().map(|(member, role)| {
|
||
Tag::parse(["p", hex::encode(member).as_str(), "", *role]).expect("p tag")
|
||
}))
|
||
.collect::<Vec<_>>();
|
||
EventBuilder::new(Kind::Custom(39002), "")
|
||
.tags(tags)
|
||
.custom_created_at(Timestamp::from(timestamp))
|
||
.sign_with_keys(&relay_keys)
|
||
.expect("sign roster")
|
||
};
|
||
|
||
let newcomer = Keys::generate().public_key().to_bytes();
|
||
sqlx::query(
|
||
"INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) \
|
||
VALUES ($1, $2, $3, 'member', $4)",
|
||
)
|
||
.bind(community_uuid)
|
||
.bind(channel)
|
||
.bind(newcomer.as_slice())
|
||
.bind(owner.as_slice())
|
||
.execute(&pool)
|
||
.await
|
||
.expect("seed member before legacy capture");
|
||
let stale_a = roster(
|
||
&[(owner.as_slice(), "owner"), (newcomer.as_slice(), "member")],
|
||
base + 2,
|
||
);
|
||
|
||
sqlx::query(
|
||
"UPDATE channel_members SET role = 'admin' \
|
||
WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3",
|
||
)
|
||
.bind(community_uuid)
|
||
.bind(channel)
|
||
.bind(newcomer.as_slice())
|
||
.execute(&pool)
|
||
.await
|
||
.expect("commit newer canonical role");
|
||
|
||
let relay_pubkey = relay_keys.public_key().to_bytes();
|
||
let mut snapshot = db
|
||
.lock_member_snapshot(community, channel, &relay_pubkey)
|
||
.await
|
||
.expect("new writer captures locked roster B");
|
||
let fresh_b = roster(
|
||
&[(owner.as_slice(), "owner"), (newcomer.as_slice(), "admin")],
|
||
base + 1,
|
||
);
|
||
assert!(
|
||
snapshot
|
||
.replace_member_event(&fresh_b)
|
||
.await
|
||
.expect("new writer publishes B")
|
||
.1
|
||
);
|
||
snapshot
|
||
.release()
|
||
.await
|
||
.expect("commit B and release locks");
|
||
|
||
// Reproduce the old binary on one connection: replacement key ->
|
||
// old-head UPDATE -> raw INSERT. Do not use the app API, whose guard
|
||
// rejects before the retained trigger can exercise this legacy fence.
|
||
// A size-one pool and bounded attempt also catch self-deadlock.
|
||
let error = tokio::time::timeout(Duration::from_secs(3), async {
|
||
let mut tx = pool.begin().await.expect("begin legacy replacement");
|
||
let relay_pubkey = relay_keys.public_key().to_bytes();
|
||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||
.bind(crate::store::replaceable::event_replacement_lock_key(
|
||
community,
|
||
39002,
|
||
&relay_pubkey,
|
||
Some(channel.as_bytes().as_slice()),
|
||
))
|
||
.execute(&mut *tx)
|
||
.await
|
||
.expect("legacy replacement key");
|
||
let deleted = sqlx::query(
|
||
"UPDATE events SET deleted_at = NOW() \
|
||
WHERE community_id=$1 AND kind=39002 AND pubkey=$2 \
|
||
AND channel_id=$3 AND deleted_at IS NULL",
|
||
)
|
||
.bind(community_uuid)
|
||
.bind(relay_pubkey.as_slice())
|
||
.bind(channel)
|
||
.execute(&mut *tx)
|
||
.await
|
||
.expect("legacy soft-delete of current head");
|
||
assert_eq!(deleted.rows_affected(), 1, "legacy path must delete the live head");
|
||
let result = sqlx::query(
|
||
"INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) \
|
||
VALUES ($1, $2, $3, $4, 39002, $5, $6, $7, NOW(), $8, $9)",
|
||
)
|
||
.bind(community_uuid)
|
||
.bind(stale_a.id.as_bytes().as_slice())
|
||
.bind(relay_pubkey.as_slice())
|
||
.bind(chrono::DateTime::from_timestamp(stale_a.created_at.as_secs() as i64, 0).expect("timestamp"))
|
||
.bind(serde_json::to_value(&stale_a.tags).expect("tags"))
|
||
.bind(&stale_a.content)
|
||
.bind(stale_a.sig.serialize().as_slice())
|
||
.bind(channel)
|
||
.bind(channel.to_string())
|
||
.execute(&mut *tx)
|
||
.await;
|
||
tx.rollback().await.expect("restore live head after legacy attempt");
|
||
result
|
||
})
|
||
.await
|
||
.expect("legacy replacement must not deadlock")
|
||
.expect_err("stale captured roster A must be rejected");
|
||
assert!(
|
||
matches!(error, sqlx::Error::Database(ref db_error)
|
||
if db_error.code().as_deref() == Some("23514")
|
||
&& db_error.message() == "kind 39002 roster does not match canonical channel membership"),
|
||
"expected trigger roster fence check violation, got {error:?}"
|
||
);
|
||
|
||
let live_ids: Vec<Vec<u8>> = sqlx::query_scalar(
|
||
"SELECT id FROM events WHERE community_id=$1 AND channel_id=$2 \
|
||
AND kind=39002 AND pubkey=$3 AND deleted_at IS NULL",
|
||
)
|
||
.bind(community_uuid)
|
||
.bind(channel)
|
||
.bind(relay_pubkey.as_slice())
|
||
.fetch_all(&pool)
|
||
.await
|
||
.expect("load live roster heads");
|
||
assert_eq!(live_ids, vec![fresh_b.id.as_bytes().to_vec()]);
|
||
let stale_rows: i64 =
|
||
sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND id=$2")
|
||
.bind(community_uuid)
|
||
.bind(stale_a.id.as_bytes().as_slice())
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("count rejected stale roster");
|
||
assert_eq!(stale_rows, 0, "stale roster insert must roll back");
|
||
|
||
drop_scratch_db(&admin, pool, &scratch_name).await;
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn nip_rs_replacement_hard_deletes_payload_and_watermark_rejects_replay() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let db = setup_db().await;
|
||
let community = CommunityId::from_uuid(make_community(&db.pool).await);
|
||
let keys = Keys::generate();
|
||
let d_tag = format!("read-state:{}", "a".repeat(32));
|
||
let tags = vec![
|
||
Tag::parse(["d", d_tag.as_str()]).expect("d tag"),
|
||
Tag::parse(["t", "read-state"]).expect("t tag"),
|
||
];
|
||
let base = Timestamp::now().as_secs();
|
||
let old = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "old")
|
||
.tags(tags.clone())
|
||
.custom_created_at(Timestamp::from(base))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign old");
|
||
let new = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "new")
|
||
.tags(tags)
|
||
.custom_created_at(Timestamp::from(base + 1))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign new");
|
||
|
||
assert!(
|
||
db.replace_parameterized_event(community, &old, &d_tag, None)
|
||
.await
|
||
.expect("insert old")
|
||
.1
|
||
);
|
||
assert!(
|
||
db.replace_parameterized_event(community, &new, &d_tag, None)
|
||
.await
|
||
.expect("replace with new")
|
||
.1
|
||
);
|
||
|
||
let rows: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) FROM events WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(&d_tag)
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("count NIP-RS rows");
|
||
assert_eq!(rows, 1, "superseded payload must be physically deleted");
|
||
|
||
sqlx::query(
|
||
"UPDATE events SET deleted_at=NOW() WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(&d_tag)
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("simulate NIP-09 coordinate deletion");
|
||
|
||
assert!(
|
||
!db.replace_parameterized_event(community, &old, &d_tag, None)
|
||
.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",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(&d_tag)
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("count live NIP-RS rows");
|
||
assert_eq!(live, 0, "watermark must block stale resurrection");
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn replaceable_writes_fail_closed_when_community_is_fenced() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let db = setup_db().await;
|
||
let community = CommunityId::from_uuid(make_community(&db.pool).await);
|
||
let store = db.deletion_store();
|
||
let host: String = sqlx::query_scalar("SELECT host FROM communities WHERE id = $1")
|
||
.bind(community.as_uuid())
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("load community host");
|
||
let submitted = store
|
||
.submit(&host, "test-operator", Some("replaceable fence regression"))
|
||
.await
|
||
.expect("submit deletion request");
|
||
let empty_digest = crate::deletion::KeyStreamDigest::new().finish().0;
|
||
let empty_storage = crate::deletion::StorageManifest {
|
||
version: 4,
|
||
prefixes: vec![
|
||
crate::deletion::PrefixManifest {
|
||
prefix: format!("_meta/{community}/"),
|
||
object_count: 0,
|
||
total_bytes: 0,
|
||
keys_digest: empty_digest.clone(),
|
||
},
|
||
crate::deletion::PrefixManifest {
|
||
prefix: format!("_uploads/{community}/"),
|
||
object_count: 0,
|
||
total_bytes: 0,
|
||
keys_digest: empty_digest.clone(),
|
||
},
|
||
crate::deletion::PrefixManifest {
|
||
prefix: format!("repos/{community}/"),
|
||
object_count: 0,
|
||
total_bytes: 0,
|
||
keys_digest: empty_digest,
|
||
},
|
||
],
|
||
};
|
||
let inventory = crate::deletion::FrozenInventory {
|
||
schema: store
|
||
.inventory_schema(community)
|
||
.await
|
||
.expect("inventory schema"),
|
||
storage: empty_storage,
|
||
};
|
||
let request = store
|
||
.freeze_inventory(submitted.id, &inventory)
|
||
.await
|
||
.expect("freeze inventory");
|
||
store
|
||
.approve(request.id, "test-approver", Some("approved"))
|
||
.await
|
||
.expect("approve request");
|
||
let claim = store
|
||
.claim_specific(
|
||
request.id,
|
||
"test-executor",
|
||
crate::deletion::DEFAULT_LEASE_DURATION,
|
||
)
|
||
.await
|
||
.expect("claim request")
|
||
.expect("claim winner");
|
||
store
|
||
.begin_quiescing(&claim.lease)
|
||
.await
|
||
.expect("begin quiescing");
|
||
store.fence(&claim.lease).await.expect("fence community");
|
||
|
||
let keys = Keys::generate();
|
||
let base = Timestamp::now().as_secs();
|
||
let addressable = EventBuilder::new(Kind::Custom(0), "fenced-addressable")
|
||
.custom_created_at(Timestamp::from(base))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign addressable event");
|
||
let addressable_error = db
|
||
.replace_addressable_event(community, &addressable, None)
|
||
.await
|
||
.expect_err("fenced community must reject addressable replacement");
|
||
assert!(
|
||
matches!(&addressable_error, DbError::AccessDenied(message) if message.contains("write-fenced")),
|
||
"expected write-fenced access denial, got: {addressable_error:#}"
|
||
);
|
||
|
||
let d_tag = format!("read-state:{}", "f".repeat(32));
|
||
let parameterized = EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16),
|
||
"fenced-parameterized",
|
||
)
|
||
.tags(vec![Tag::parse(["d", d_tag.as_str()]).expect("d tag")])
|
||
.custom_created_at(Timestamp::from(base + 1))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign parameterized event");
|
||
let parameterized_error = db
|
||
.replace_parameterized_event(community, ¶meterized, &d_tag, None)
|
||
.await
|
||
.expect_err("fenced community must reject parameterized replacement");
|
||
assert!(
|
||
matches!(¶meterized_error, DbError::AccessDenied(message) if message.contains("write-fenced")),
|
||
"expected write-fenced access denial, got: {parameterized_error:#}"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn migration_schema_nip_rs_transaction_operation_restores_hard_delete_opt_in() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let db = setup_db().await;
|
||
let community = CommunityId::from_uuid(make_community(&db.pool).await);
|
||
let keys = Keys::generate();
|
||
let base = Timestamp::now().as_secs();
|
||
let replace_d_tag = format!("read-state:{}", "b".repeat(32));
|
||
let victim_d_tag = format!("read-state:{}", "c".repeat(32));
|
||
let event = |d_tag: &str, content: &str, timestamp: u64| {
|
||
EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16),
|
||
content,
|
||
)
|
||
.tags(vec![
|
||
Tag::parse(["d", d_tag]).expect("d tag"),
|
||
Tag::parse(["t", "read-state"]).expect("t tag"),
|
||
])
|
||
.custom_created_at(Timestamp::from(timestamp))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign read state")
|
||
};
|
||
let old = event(&replace_d_tag, "old", base);
|
||
let new = event(&replace_d_tag, "new", base + 1);
|
||
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
|
||
);
|
||
assert!(
|
||
db.replace_parameterized_event(community, &victim, &victim_d_tag, None)
|
||
.await
|
||
.expect("insert victim head")
|
||
.1
|
||
);
|
||
|
||
let mut tx = db
|
||
.begin_event_write_transaction(community)
|
||
.await
|
||
.expect("begin caller transaction");
|
||
let result = db
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
&new,
|
||
&replace_d_tag,
|
||
None,
|
||
replaceable::ParameterizedReplacePrecondition::Unconditional,
|
||
)
|
||
.await
|
||
.expect("replace inside caller transaction");
|
||
assert_eq!(
|
||
result.status,
|
||
replaceable::ParameterizedReplaceStatus::Inserted
|
||
);
|
||
|
||
let leaked: Option<String> = sqlx::query_scalar(
|
||
"SELECT NULLIF(current_setting('buzz.nip_rs_hard_delete', true), '')",
|
||
)
|
||
.fetch_one(tx.conn())
|
||
.await
|
||
.expect("read hard-delete opt-in after replacement");
|
||
assert_ne!(leaked.as_deref(), Some("on"));
|
||
|
||
let unauthorized = sqlx::query(
|
||
"DELETE FROM events WHERE community_id=$1 AND kind=30078 \
|
||
AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(&victim_d_tag)
|
||
.execute(tx.conn())
|
||
.await;
|
||
assert!(
|
||
unauthorized.is_err(),
|
||
"replacement opt-in must not authorize later caller SQL"
|
||
);
|
||
tx.rollback().await.expect("roll back caller transaction");
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn parameterized_replacement_in_existing_transaction_honors_revision_and_rollback() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let db = setup_db().await;
|
||
let community = CommunityId::from_uuid(make_community(&db.pool).await);
|
||
let keys = Keys::generate();
|
||
let d_tag = format!("transactional-project-{}", Uuid::new_v4().simple());
|
||
let base = Timestamp::now().as_secs();
|
||
let event = |content: &str, timestamp: u64| {
|
||
EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), content)
|
||
.tags(vec![Tag::parse(["d", d_tag.as_str()]).expect("d tag")])
|
||
.custom_created_at(Timestamp::from(timestamp))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign project")
|
||
};
|
||
let old = event("old", base);
|
||
let new = event("new", base + 1);
|
||
|
||
assert!(
|
||
db.replace_parameterized_event(community, &old, &d_tag, None)
|
||
.await
|
||
.expect("insert old head")
|
||
.1
|
||
);
|
||
|
||
let mut tx = db
|
||
.begin_event_write_transaction(community)
|
||
.await
|
||
.expect("begin replacement tx");
|
||
let outcome = db
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
&new,
|
||
&d_tag,
|
||
None,
|
||
replaceable::ParameterizedReplacePrecondition::ExpectedRevision(
|
||
old.id.as_bytes().as_slice(),
|
||
),
|
||
)
|
||
.await
|
||
.expect("replace inside caller transaction");
|
||
assert_eq!(
|
||
outcome.status,
|
||
replaceable::ParameterizedReplaceStatus::Inserted
|
||
);
|
||
tx.rollback().await.expect("roll back replacement tx");
|
||
|
||
let live_id: Vec<u8> = sqlx::query_scalar(
|
||
"SELECT id FROM events WHERE community_id=$1 AND kind=$2 AND pubkey=$3 \
|
||
AND d_tag=$4 AND deleted_at IS NULL",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(buzz_core::kind::KIND_PROJECT as i32)
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(&d_tag)
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("load live head after rollback");
|
||
assert_eq!(live_id, old.id.as_bytes().to_vec());
|
||
|
||
let mut tx = db
|
||
.begin_event_write_transaction(community)
|
||
.await
|
||
.expect("begin stale revision tx");
|
||
let mismatch = db
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
&new,
|
||
&d_tag,
|
||
None,
|
||
replaceable::ParameterizedReplacePrecondition::ExpectedRevision(
|
||
[0x42; 32].as_slice(),
|
||
),
|
||
)
|
||
.await
|
||
.expect("evaluate stale revision");
|
||
assert_eq!(
|
||
mismatch.status,
|
||
replaceable::ParameterizedReplaceStatus::RevisionMismatch
|
||
);
|
||
tx.rollback().await.expect("roll back stale revision tx");
|
||
|
||
let missing_d_tag = format!("missing-project-{}", Uuid::new_v4().simple());
|
||
let missing = EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_PROJECT as u16),
|
||
"missing",
|
||
)
|
||
.tags(vec![
|
||
Tag::parse(["d", missing_d_tag.as_str()]).expect("missing d tag")
|
||
])
|
||
.custom_created_at(Timestamp::from(base + 2))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign missing project");
|
||
let mut tx = db
|
||
.begin_event_write_transaction(community)
|
||
.await
|
||
.expect("begin missing revision tx");
|
||
let missing_result = db
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
&missing,
|
||
&missing_d_tag,
|
||
None,
|
||
replaceable::ParameterizedReplacePrecondition::ExpectedRevision(
|
||
[0x24; 32].as_slice(),
|
||
),
|
||
)
|
||
.await
|
||
.expect("evaluate missing revision");
|
||
assert_eq!(
|
||
missing_result.status,
|
||
replaceable::ParameterizedReplaceStatus::RevisionMissing
|
||
);
|
||
tx.rollback().await.expect("roll back missing revision tx");
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn parameterized_replacement_rolls_back_when_mention_indexing_fails() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let admin = PgPool::connect(&admin_url().await)
|
||
.await
|
||
.expect("connect admin");
|
||
let (pool, scratch_name) = create_scratch_db(&admin, "atomic_parameterized").await;
|
||
let db = Db::from_pool(pool.clone());
|
||
let community = CommunityId::from_uuid(make_community(&pool).await);
|
||
let keys = Keys::generate();
|
||
let mentioned = Keys::generate().public_key().to_hex();
|
||
let d_tag = format!("mention-project-{}", Uuid::new_v4().simple());
|
||
let tags = || {
|
||
vec![
|
||
Tag::parse(["d", d_tag.as_str()]).expect("d tag"),
|
||
Tag::parse(["p", mentioned.as_str()]).expect("p tag"),
|
||
]
|
||
};
|
||
let base = Timestamp::now().as_secs();
|
||
let old = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), "old")
|
||
.tags(tags())
|
||
.custom_created_at(Timestamp::from(base))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign old project");
|
||
let new = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), "new")
|
||
.tags(tags())
|
||
.custom_created_at(Timestamp::from(base + 1))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign new project");
|
||
|
||
assert!(
|
||
db.replace_parameterized_event(community, &old, &d_tag, None)
|
||
.await
|
||
.expect("insert old project")
|
||
.1
|
||
);
|
||
sqlx::query(
|
||
"CREATE FUNCTION reject_test_mention() RETURNS trigger AS $$ \
|
||
BEGIN RAISE EXCEPTION 'injected mention failure'; END; \
|
||
$$ LANGUAGE plpgsql",
|
||
)
|
||
.execute(&pool)
|
||
.await
|
||
.expect("create failure function");
|
||
sqlx::query(
|
||
"CREATE TRIGGER reject_test_mention BEFORE INSERT ON event_mentions \
|
||
FOR EACH ROW EXECUTE FUNCTION reject_test_mention()",
|
||
)
|
||
.execute(&pool)
|
||
.await
|
||
.expect("install failure injection");
|
||
|
||
let mut tx = db
|
||
.begin_event_write_transaction(community)
|
||
.await
|
||
.expect("begin caller transaction");
|
||
let error = db
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
&new,
|
||
&d_tag,
|
||
None,
|
||
replaceable::ParameterizedReplacePrecondition::Unconditional,
|
||
)
|
||
.await
|
||
.expect_err("mention failure must fail replacement");
|
||
assert!(error.to_string().contains("injected mention failure"));
|
||
|
||
let probe: i32 = sqlx::query_scalar("SELECT 1")
|
||
.fetch_one(tx.conn())
|
||
.await
|
||
.expect("inner failure must leave caller transaction usable");
|
||
assert_eq!(probe, 1);
|
||
|
||
let live_id: Vec<u8> = sqlx::query_scalar(
|
||
"SELECT id FROM events WHERE community_id=$1 AND kind=$2 AND pubkey=$3 \
|
||
AND d_tag=$4 AND deleted_at IS NULL",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(buzz_core::kind::KIND_PROJECT as i32)
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(&d_tag)
|
||
.fetch_one(tx.conn())
|
||
.await
|
||
.expect("load live project after failed indexing");
|
||
assert_eq!(live_id, old.id.as_bytes().to_vec());
|
||
let new_rows: i64 =
|
||
sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND id=$2")
|
||
.bind(community.as_uuid())
|
||
.bind(new.id.as_bytes().as_slice())
|
||
.fetch_one(tx.conn())
|
||
.await
|
||
.expect("count rolled-back project");
|
||
assert_eq!(new_rows, 0);
|
||
tx.commit().await.expect("commit usable caller transaction");
|
||
|
||
drop_scratch_db(&admin, pool, &scratch_name).await;
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn parameterized_duplicate_restores_live_head_inside_caller_transaction() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let db = setup_db().await;
|
||
let community = CommunityId::from_uuid(make_community(&db.pool).await);
|
||
let keys = Keys::generate();
|
||
let d_tag = format!("duplicate-project-{}", Uuid::new_v4().simple());
|
||
let base = Timestamp::now().as_secs();
|
||
let event = |content: &str, timestamp: u64| {
|
||
EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), content)
|
||
.tags(vec![Tag::parse(["d", d_tag.as_str()]).expect("d tag")])
|
||
.custom_created_at(Timestamp::from(timestamp))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign project")
|
||
};
|
||
let old = event("old-live-head", base);
|
||
let duplicate = event("soft-deleted-duplicate", base + 1);
|
||
|
||
assert!(
|
||
db.replace_parameterized_event(community, &duplicate, &d_tag, None)
|
||
.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())
|
||
.bind(duplicate.id.as_bytes().as_slice())
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("soft-delete duplicate row");
|
||
|
||
let mut seed_tx = db
|
||
.begin_event_write_transaction(community)
|
||
.await
|
||
.expect("begin seed transaction");
|
||
let (_, was_inserted) = event::insert_event_in_transaction(&mut seed_tx, &old, None)
|
||
.await
|
||
.expect("insert older live head");
|
||
assert!(was_inserted);
|
||
seed_tx.commit().await.expect("commit older live head");
|
||
|
||
let mut tx = db
|
||
.begin_event_write_transaction(community)
|
||
.await
|
||
.expect("begin caller transaction");
|
||
let result = db
|
||
.replace_parameterized_event_in_transaction(
|
||
&mut tx,
|
||
&duplicate,
|
||
&d_tag,
|
||
None,
|
||
replaceable::ParameterizedReplacePrecondition::Unconditional,
|
||
)
|
||
.await
|
||
.expect("evaluate soft-deleted duplicate");
|
||
assert_eq!(
|
||
result.status,
|
||
replaceable::ParameterizedReplaceStatus::Duplicate
|
||
);
|
||
|
||
let live_id: Vec<u8> = sqlx::query_scalar(
|
||
"SELECT id FROM events WHERE community_id=$1 AND kind=$2 AND pubkey=$3 \
|
||
AND d_tag=$4 AND deleted_at IS NULL",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(buzz_core::kind::KIND_PROJECT as i32)
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(&d_tag)
|
||
.fetch_one(tx.conn())
|
||
.await
|
||
.expect("caller transaction remains usable after duplicate");
|
||
assert_eq!(live_id, old.id.as_bytes().to_vec());
|
||
tx.rollback().await.expect("roll back caller transaction");
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn concurrent_parameterized_replacement_keeps_deterministic_head() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let db = setup_db().await;
|
||
let community = CommunityId::from_uuid(make_community(&db.pool).await);
|
||
let keys = Keys::generate();
|
||
let d_tag = format!("concurrent-project-{}", Uuid::new_v4().simple());
|
||
let created_at = Timestamp::now().as_secs();
|
||
let event = |content: &str, timestamp: u64| {
|
||
EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), content)
|
||
.tags(vec![Tag::parse(["d", d_tag.as_str()]).expect("d tag")])
|
||
.custom_created_at(Timestamp::from(timestamp))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign project")
|
||
};
|
||
let first = event("first", created_at);
|
||
let second = event("second", created_at);
|
||
let expected = if first.id.as_bytes() < second.id.as_bytes() {
|
||
&first
|
||
} else {
|
||
&second
|
||
};
|
||
|
||
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),
|
||
);
|
||
let first_inserted = first_result.expect("first concurrent writer").1;
|
||
let second_inserted = second_result.expect("second concurrent writer").1;
|
||
assert!(
|
||
first_inserted || second_inserted,
|
||
"at least one concurrent writer must insert",
|
||
);
|
||
|
||
let live_ids: Vec<Vec<u8>> = sqlx::query_scalar(
|
||
"SELECT id FROM events WHERE community_id=$1 AND kind=$2 AND pubkey=$3 \
|
||
AND d_tag=$4 AND deleted_at IS NULL",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(buzz_core::kind::KIND_PROJECT as i32)
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(&d_tag)
|
||
.fetch_all(&db.pool)
|
||
.await
|
||
.expect("load concurrent live head");
|
||
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,
|
||
"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,
|
||
"an older event must not replace the live head",
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn migration_schema_mesh_status_replacement_keeps_one_physical_row() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let db = setup_db().await;
|
||
let community = CommunityId::from_uuid(make_community(&db.pool).await);
|
||
let keys = Keys::generate();
|
||
let d_tag = "buzz-mesh-member-status:owner-test";
|
||
let tags = vec![
|
||
Tag::parse(["d", d_tag]).expect("d tag"),
|
||
Tag::parse(["k", "buzz-mesh-status"]).expect("k tag"),
|
||
];
|
||
let base = Timestamp::now().as_secs();
|
||
for (offset, content) in [(0, "running"), (1, "running-again"), (2, "stopped")] {
|
||
let event = EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_BOOKMARK_SET as u16),
|
||
content,
|
||
)
|
||
.tags(tags.clone())
|
||
.custom_created_at(Timestamp::from(base + offset))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign mesh status");
|
||
assert!(
|
||
db.replace_parameterized_event(community, &event, d_tag, None)
|
||
.await
|
||
.expect("replace mesh status")
|
||
.1
|
||
);
|
||
}
|
||
|
||
let (rows, live): (i64, i64) = sqlx::query_as(
|
||
"SELECT count(*), count(*) FILTER (WHERE deleted_at IS NULL) FROM events \
|
||
WHERE community_id=$1 AND kind=30003 AND pubkey=$2 AND d_tag=$3",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(d_tag)
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("count mesh status rows");
|
||
assert_eq!((rows, live), (1, 1));
|
||
|
||
sqlx::query(
|
||
"UPDATE events SET deleted_at=NOW() \
|
||
WHERE community_id=$1 AND kind=30003 AND pubkey=$2 AND d_tag=$3",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(d_tag)
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("simulate old relay soft delete");
|
||
let rows_after_legacy_delete: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) FROM events \
|
||
WHERE community_id=$1 AND kind=30003 AND pubkey=$2 AND d_tag=$3",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(d_tag)
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("count rows after old relay soft delete");
|
||
assert_eq!(
|
||
rows_after_legacy_delete, 0,
|
||
"migration trigger must purge soft-deleted mesh status"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn duplicate_nip_rs_discriminator_tags_keep_legacy_retention() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let db = setup_db().await;
|
||
let community = CommunityId::from_uuid(make_community(&db.pool).await);
|
||
let keys = Keys::generate();
|
||
let base = Timestamp::now().as_secs();
|
||
|
||
for (case, tags) in [
|
||
(
|
||
"duplicate-d",
|
||
vec![
|
||
Tag::parse(["d", &format!("read-state:{}", "c".repeat(32))])
|
||
.expect("first d tag"),
|
||
Tag::parse(["d", &format!("read-state:{}", "d".repeat(32))])
|
||
.expect("second d tag"),
|
||
Tag::parse(["t", "read-state"]).expect("t tag"),
|
||
],
|
||
),
|
||
(
|
||
"duplicate-t",
|
||
vec![
|
||
Tag::parse(["d", &format!("read-state:{}", "e".repeat(32))]).expect("d tag"),
|
||
Tag::parse(["t", "read-state"]).expect("first t tag"),
|
||
Tag::parse(["t", "read-state"]).expect("second t tag"),
|
||
],
|
||
),
|
||
] {
|
||
let d_tag = tags
|
||
.iter()
|
||
.find_map(|tag| {
|
||
let parts = tag.as_slice();
|
||
(parts.first().is_some_and(|part| part == "d") && parts.len() >= 2)
|
||
.then(|| parts[1].clone())
|
||
})
|
||
.expect("first d-tag value");
|
||
let old = EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16),
|
||
format!("{case}-old"),
|
||
)
|
||
.tags(tags.clone())
|
||
.custom_created_at(Timestamp::from(base))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign old event");
|
||
let new = EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16),
|
||
format!("{case}-new"),
|
||
)
|
||
.tags(tags)
|
||
.custom_created_at(Timestamp::from(base + 1))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign new event");
|
||
|
||
assert!(
|
||
db.replace_parameterized_event(community, &old, &d_tag, None)
|
||
.await
|
||
.expect("insert old event")
|
||
.1
|
||
);
|
||
assert!(
|
||
db.replace_parameterized_event(community, &new, &d_tag, None)
|
||
.await
|
||
.expect("replace with new event")
|
||
.1
|
||
);
|
||
|
||
let (rows, live): (i64, i64) = sqlx::query_as(
|
||
"SELECT count(*), count(*) FILTER (WHERE deleted_at IS NULL) FROM events \
|
||
WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(&d_tag)
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("count retained rows");
|
||
assert_eq!((rows, live), (2, 1), "{case} must retain legacy history");
|
||
|
||
let watermarks: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) FROM parameterized_event_watermarks \
|
||
WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(&d_tag)
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("count watermarks");
|
||
assert_eq!(watermarks, 0, "{case} must not create a watermark");
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn migration_schema_nip_rs_hard_delete_fence_fails_closed_and_scopes_opt_in_to_transaction(
|
||
) {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
let db = setup_db().await;
|
||
let community = CommunityId::from_uuid(make_community(&db.pool).await);
|
||
let keys = Keys::generate();
|
||
let base = Timestamp::now().as_secs();
|
||
let conforming_d = format!("read-state:{}", "6".repeat(32));
|
||
let conforming = EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16),
|
||
"fenced-conforming",
|
||
)
|
||
.tags(vec![
|
||
Tag::parse(["d", conforming_d.as_str()]).expect("d tag"),
|
||
Tag::parse(["t", "read-state"]).expect("t tag"),
|
||
])
|
||
.custom_created_at(Timestamp::from(base))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign conforming event");
|
||
assert!(
|
||
db.replace_parameterized_event(community, &conforming, &conforming_d, None)
|
||
.await
|
||
.expect("insert conforming event")
|
||
.1
|
||
);
|
||
sqlx::query(
|
||
"INSERT INTO event_mentions \
|
||
(community_id, pubkey_hex, event_id, event_created_at, event_kind) \
|
||
VALUES ($1, $2, $3, to_timestamp($4), 30078)",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind("6".repeat(64))
|
||
.bind(conforming.id.as_bytes().as_slice())
|
||
.bind(conforming.created_at.as_secs() as f64)
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("insert mention");
|
||
|
||
// Model ce10's first destructive statement. RAISE aborts the transaction,
|
||
// so its later mention delete and incoming insert can never commit.
|
||
let mut old_writer = db.pool.begin().await.expect("begin old-writer tx");
|
||
let rejected = sqlx::query(
|
||
"DELETE FROM events WHERE community_id=$1 AND kind=30078 \
|
||
AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(keys.public_key().to_bytes())
|
||
.bind(&conforming_d)
|
||
.execute(&mut *old_writer)
|
||
.await;
|
||
assert!(rejected.is_err(), "old-writer hard delete must be rejected");
|
||
old_writer.rollback().await.expect("rollback rejected tx");
|
||
let preserved: (i64, i64) = sqlx::query_as(
|
||
"SELECT (SELECT count(*) FROM events WHERE community_id=$1 AND id=$2), \
|
||
(SELECT count(*) FROM event_mentions WHERE community_id=$1 AND event_id=$2)",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(conforming.id.as_bytes().as_slice())
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("count preserved payload and mention");
|
||
assert_eq!(preserved, (1, 1));
|
||
|
||
let nonconforming_d = format!("read-state:{}", "7".repeat(32));
|
||
let nonconforming = EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16),
|
||
"fenced-nonconforming",
|
||
)
|
||
.tags(vec![
|
||
Tag::parse(["d", nonconforming_d.as_str()]).expect("first d tag"),
|
||
Tag::parse(["d", "other"]).expect("second d tag"),
|
||
Tag::parse(["t", "read-state"]).expect("t tag"),
|
||
])
|
||
.custom_created_at(Timestamp::from(base + 1))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign nonconforming event");
|
||
assert!(
|
||
db.replace_parameterized_event(community, &nonconforming, &nonconforming_d, None,)
|
||
.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)",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(nonconforming.id.as_bytes().as_slice())
|
||
.bind(nonconforming.created_at.as_secs() as f64)
|
||
.execute(&db.pool)
|
||
.await;
|
||
assert!(
|
||
rejected_nonconforming.is_err(),
|
||
"fence must cover a nonconforming OLD row at a regex coordinate"
|
||
);
|
||
|
||
let unrelated_d = format!("read-state:{}", "8".repeat(32));
|
||
let unrelated = EventBuilder::new(Kind::Custom(30023), "unrelated")
|
||
.tags(vec![Tag::parse(["d", unrelated_d.as_str()]).expect("d tag")])
|
||
.custom_created_at(Timestamp::from(base + 2))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign unrelated event");
|
||
assert!(
|
||
db.replace_parameterized_event(community, &unrelated, &unrelated_d, None)
|
||
.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)",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(unrelated.id.as_bytes().as_slice())
|
||
.bind(unrelated.created_at.as_secs() as f64)
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("delete unrelated event");
|
||
assert_eq!(unrelated_delete.rows_affected(), 1);
|
||
|
||
// Check both transaction exits on one physical session; pool selection
|
||
// cannot accidentally hide a leaked session-local authorization value.
|
||
let mut conn = db.pool.acquire().await.expect("acquire dedicated session");
|
||
for commit in [true, false] {
|
||
let mut tx = conn.begin().await.expect("begin GUC transaction");
|
||
let value: String =
|
||
sqlx::query_scalar("SELECT set_config('buzz.nip_rs_hard_delete', 'on', true)")
|
||
.fetch_one(&mut *tx)
|
||
.await
|
||
.expect("set transaction-local GUC");
|
||
assert_eq!(value, "on");
|
||
if commit {
|
||
tx.commit().await.expect("commit GUC transaction");
|
||
} else {
|
||
tx.rollback().await.expect("rollback GUC transaction");
|
||
}
|
||
let leaked: Option<String> = sqlx::query_scalar(
|
||
"SELECT NULLIF(current_setting('buzz.nip_rs_hard_delete', true), '')",
|
||
)
|
||
.fetch_one(&mut *conn)
|
||
.await
|
||
.expect("read GUC after transaction");
|
||
assert_ne!(leaked.as_deref(), Some("on"));
|
||
}
|
||
}
|
||
|
||
/// NIP-09 deletion of a retention-free coordinate (NIP-RS read state, mesh
|
||
/// status) is physical in application code, so it holds on desired-state
|
||
/// databases that never installed the migration 0009/0019 purge triggers.
|
||
/// Ordinary addressable events, and look-alikes that fail classification,
|
||
/// keep the soft-delete contract.
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn nip09_deletion_purges_retention_free_rows_and_mentions() {
|
||
assert_nip09_deletion_purges_retention_free_rows_and_mentions(false).await;
|
||
}
|
||
|
||
/// The same contract on a migration-built database, where the 0009/0011/
|
||
/// 0019 purge and hard-delete fence triggers are still installed. Each row
|
||
/// shape is also soft-deleted the way a pre-0009 writer would, so the
|
||
/// table proves the app classification agrees with the triggers.
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn migration_schema_nip09_deletion_purges_retention_free_rows_and_mentions() {
|
||
assert_nip09_deletion_purges_retention_free_rows_and_mentions(true).await;
|
||
}
|
||
|
||
async fn assert_nip09_deletion_purges_retention_free_rows_and_mentions(migration: bool) {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
|
||
#[derive(Clone, Copy, Debug)]
|
||
enum Delete {
|
||
Coordinate,
|
||
Id,
|
||
}
|
||
|
||
let db = setup_db().await;
|
||
let triggers_installed: bool = sqlx::query_scalar(
|
||
"SELECT EXISTS(SELECT 1 FROM pg_trigger \
|
||
WHERE tgname = 'trg_events_purge_soft_deleted_nip_rs')",
|
||
)
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("inspect purge trigger");
|
||
assert_eq!(
|
||
triggers_installed, migration,
|
||
"schema mode must match the test variant (run through the Postgres wrapper)"
|
||
);
|
||
let community = CommunityId::from_uuid(make_community(&db.pool).await);
|
||
let nip_rs = buzz_core::kind::KIND_READ_STATE as u16;
|
||
let mesh = buzz_core::kind::KIND_BOOKMARK_SET as u16;
|
||
let read_state_d = format!("read-state:{}", "b".repeat(32));
|
||
let mesh_d = "buzz-mesh-member-status:purge-test".to_string();
|
||
let tag = |parts: &[&str]| parts.iter().map(|p| p.to_string()).collect::<Vec<_>>();
|
||
let t_read_state = tag(&["t", "read-state"]);
|
||
let k_mesh = tag(&["k", "buzz-mesh-status"]);
|
||
// (label, kind, column d_tag, tags, physically purged)
|
||
type Case = (&'static str, u16, String, Vec<Vec<String>>, bool);
|
||
let cases: Vec<Case> = vec![
|
||
(
|
||
"nip-rs",
|
||
nip_rs,
|
||
read_state_d.clone(),
|
||
vec![tag(&["d", &read_state_d]), t_read_state.clone()],
|
||
true,
|
||
),
|
||
(
|
||
"nip-rs without t tag",
|
||
nip_rs,
|
||
read_state_d.clone(),
|
||
vec![tag(&["d", &read_state_d])],
|
||
false,
|
||
),
|
||
(
|
||
"nip-rs uppercase hex slot",
|
||
nip_rs,
|
||
format!("read-state:{}", "B".repeat(32)),
|
||
vec![
|
||
tag(&["d", &format!("read-state:{}", "B".repeat(32))]),
|
||
t_read_state.clone(),
|
||
],
|
||
false,
|
||
),
|
||
(
|
||
"nip-rs 31-char slot",
|
||
nip_rs,
|
||
format!("read-state:{}", "b".repeat(31)),
|
||
vec![
|
||
tag(&["d", &format!("read-state:{}", "b".repeat(31))]),
|
||
t_read_state.clone(),
|
||
],
|
||
false,
|
||
),
|
||
(
|
||
"nip-rs duplicate d tag",
|
||
nip_rs,
|
||
read_state_d.clone(),
|
||
vec![
|
||
tag(&["d", &read_state_d]),
|
||
tag(&["d", &read_state_d]),
|
||
t_read_state.clone(),
|
||
],
|
||
false,
|
||
),
|
||
(
|
||
"nip-rs duplicate t tag",
|
||
nip_rs,
|
||
read_state_d.clone(),
|
||
vec![
|
||
tag(&["d", &read_state_d]),
|
||
t_read_state.clone(),
|
||
t_read_state.clone(),
|
||
],
|
||
false,
|
||
),
|
||
(
|
||
"nip-rs d tag differs from column",
|
||
nip_rs,
|
||
read_state_d.clone(),
|
||
vec![
|
||
tag(&["d", &format!("read-state:{}", "c".repeat(32))]),
|
||
t_read_state.clone(),
|
||
],
|
||
false,
|
||
),
|
||
(
|
||
"mesh-status",
|
||
mesh,
|
||
mesh_d.clone(),
|
||
vec![tag(&["d", &mesh_d]), k_mesh.clone()],
|
||
true,
|
||
),
|
||
(
|
||
// Containment semantics (`tags @>`), shared by trigger and app.
|
||
"mesh-status k tag with extra element",
|
||
mesh,
|
||
mesh_d.clone(),
|
||
vec![
|
||
tag(&["d", &mesh_d]),
|
||
tag(&["k", "buzz-mesh-status", "extra"]),
|
||
],
|
||
true,
|
||
),
|
||
(
|
||
"mesh-status wrong d prefix",
|
||
mesh,
|
||
"buzz-mesh-member:purge-test".into(),
|
||
vec![tag(&["d", "buzz-mesh-member:purge-test"]), k_mesh.clone()],
|
||
false,
|
||
),
|
||
(
|
||
"mesh-status without k tag",
|
||
mesh,
|
||
mesh_d.clone(),
|
||
vec![tag(&["d", &mesh_d])],
|
||
false,
|
||
),
|
||
(
|
||
"mesh-status wrong k value",
|
||
mesh,
|
||
mesh_d.clone(),
|
||
vec![tag(&["d", &mesh_d]), tag(&["k", "buzz-mesh-other"])],
|
||
false,
|
||
),
|
||
(
|
||
"ordinary addressable",
|
||
buzz_core::kind::KIND_PROJECT as u16,
|
||
"retained-project".into(),
|
||
vec![tag(&["d", "retained-project"])],
|
||
false,
|
||
),
|
||
];
|
||
|
||
// Store one event of this shape with one mention; return
|
||
// (keys, id, created_at secs).
|
||
let store = |label: &'static str, kind: u16, d_tag: String, tags: Vec<Vec<String>>| {
|
||
let db = &db;
|
||
async move {
|
||
let keys = Keys::generate();
|
||
let created_at = Timestamp::now().as_secs();
|
||
let event = EventBuilder::new(Kind::Custom(kind), label)
|
||
.tags(
|
||
tags.iter()
|
||
.map(|tag| Tag::parse(tag.clone()).expect("tag"))
|
||
.collect::<Vec<_>>(),
|
||
)
|
||
.custom_created_at(Timestamp::from(created_at))
|
||
.sign_with_keys(&keys)
|
||
.expect("sign event");
|
||
assert!(
|
||
db.replace_parameterized_event(community, &event, &d_tag, None)
|
||
.await
|
||
.expect("store event")
|
||
.1,
|
||
"{label}: event must be stored"
|
||
);
|
||
let id = event.id.as_bytes().to_vec();
|
||
sqlx::query(
|
||
"INSERT INTO event_mentions \
|
||
(community_id, pubkey_hex, event_id, event_created_at, channel_id, event_kind) \
|
||
SELECT community_id, $3, id, created_at, NULL, kind \
|
||
FROM events WHERE community_id = $1 AND id = $2",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(&id)
|
||
.bind("c".repeat(64))
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("index mention");
|
||
(keys, id, created_at)
|
||
}
|
||
};
|
||
// (event rows, live event rows, mention rows) for one id.
|
||
let state = |id: Vec<u8>| {
|
||
let pool = db.pool.clone();
|
||
async move {
|
||
let (rows, live): (i64, i64) = sqlx::query_as(
|
||
"SELECT count(*), count(*) FILTER (WHERE deleted_at IS NULL) \
|
||
FROM events WHERE community_id=$1 AND id=$2",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(&id)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("count event rows");
|
||
let mentions: i64 = sqlx::query_scalar(
|
||
"SELECT count(*) FROM event_mentions WHERE community_id=$1 AND event_id=$2",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(&id)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.expect("count mentions");
|
||
(rows, live, mentions)
|
||
}
|
||
};
|
||
let expected = |purged: bool| -> (i64, i64, i64) {
|
||
if purged {
|
||
(0, 0, 0)
|
||
} else {
|
||
(1, 0, 1)
|
||
}
|
||
};
|
||
|
||
for (label, kind, d_tag, tags, purged) in cases {
|
||
if migration {
|
||
// Trigger half of the parity table: a pre-0009 writer's plain
|
||
// soft delete, classified only by the 0009/0011/0019 triggers.
|
||
let (_, id, _) = store(label, kind, d_tag.clone(), tags.clone()).await;
|
||
sqlx::query("UPDATE events SET deleted_at = NOW() WHERE community_id=$1 AND id=$2")
|
||
.bind(community.as_uuid())
|
||
.bind(&id)
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("legacy soft delete");
|
||
assert_eq!(
|
||
state(id).await,
|
||
expected(purged),
|
||
"{label}: trigger classification"
|
||
);
|
||
}
|
||
|
||
for mode in [Delete::Coordinate, Delete::Id] {
|
||
let (keys, id, created_at) = store(label, kind, d_tag.clone(), tags.clone()).await;
|
||
let delete = || async {
|
||
match mode {
|
||
Delete::Coordinate => event::soft_delete_by_coordinate(
|
||
&db.pool,
|
||
community,
|
||
i32::from(kind),
|
||
&keys.public_key().to_bytes(),
|
||
&d_tag,
|
||
(created_at + 1) as i64,
|
||
)
|
||
.await
|
||
.expect("coordinate delete"),
|
||
Delete::Id => event::soft_delete_event_and_update_thread(
|
||
&db.pool, community, &id, None, None,
|
||
)
|
||
.await
|
||
.expect("id delete"),
|
||
}
|
||
};
|
||
assert!(
|
||
delete().await,
|
||
"{label}/{mode:?}: deletion must report success"
|
||
);
|
||
assert_eq!(
|
||
state(id.clone()).await,
|
||
expected(purged),
|
||
"{label}/{mode:?}: app classification"
|
||
);
|
||
assert!(
|
||
!delete().await,
|
||
"{label}/{mode:?}: repeated deletion is a no-op"
|
||
);
|
||
}
|
||
}
|
||
|
||
// A mesh-status-shaped row whose `d_tag` column was never backfilled:
|
||
// `kind = 30003 AND NULL LIKE … AND tags @> …` makes the purge
|
||
// predicate NULL (a read-state row cannot: its tag/column EXISTS is
|
||
// false), which the triggers treat as false. The id path must still
|
||
// soft-delete it (the `COALESCE` in the generic soft delete), not
|
||
// silently skip it. Coordinate deletes key on `d_tag`, so only the id
|
||
// path can reach this row.
|
||
let null_d_tag_row = || async {
|
||
let (_, id, _) = store(
|
||
"mesh-status with NULL d_tag column",
|
||
mesh,
|
||
mesh_d.clone(),
|
||
vec![tag(&["d", &mesh_d]), k_mesh.clone()],
|
||
)
|
||
.await;
|
||
sqlx::query("UPDATE events SET d_tag = NULL WHERE community_id=$1 AND id=$2")
|
||
.bind(community.as_uuid())
|
||
.bind(&id)
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("clear d_tag column");
|
||
id
|
||
};
|
||
if migration {
|
||
let id = null_d_tag_row().await;
|
||
sqlx::query("UPDATE events SET deleted_at = NOW() WHERE community_id=$1 AND id=$2")
|
||
.bind(community.as_uuid())
|
||
.bind(&id)
|
||
.execute(&db.pool)
|
||
.await
|
||
.expect("legacy soft delete");
|
||
assert_eq!(
|
||
state(id).await,
|
||
expected(false),
|
||
"NULL d_tag: trigger classification"
|
||
);
|
||
}
|
||
let id = null_d_tag_row().await;
|
||
assert!(
|
||
event::soft_delete_event_and_update_thread(&db.pool, community, &id, None, None)
|
||
.await
|
||
.expect("id delete"),
|
||
"NULL d_tag/Id: deletion must report success"
|
||
);
|
||
assert_eq!(
|
||
state(id).await,
|
||
expected(false),
|
||
"NULL d_tag/Id: app classification"
|
||
);
|
||
}
|
||
|
||
/// A coordinate deletion racing a NIP-RS replacement must never leave a
|
||
/// soft-deleted read-state row behind. The purge waits on the old head's
|
||
/// row lock; the replacement commits a newer head that the purge's snapshot
|
||
/// cannot see; the generic soft delete then runs on a fresh snapshot and
|
||
/// must spare that head rather than soft-delete it.
|
||
#[tokio::test]
|
||
#[ignore = "requires Postgres"]
|
||
async fn coordinate_deletion_racing_nip_rs_replacement_never_soft_deletes_read_state() {
|
||
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
|
||
use sqlx::postgres::PgConnectOptions;
|
||
use std::str::FromStr;
|
||
|
||
let db = setup_db().await;
|
||
let community = CommunityId::from_uuid(make_community(&db.pool).await);
|
||
let keys = Keys::generate();
|
||
let d_tag = format!("read-state:{}", "d".repeat(32));
|
||
let created_at = Timestamp::now().as_secs();
|
||
let old_head = EventBuilder::new(
|
||
Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16),
|
||
"old head",
|
||
)
|
||
.tags([
|
||
Tag::parse(["d", d_tag.as_str()]).expect("d tag"),
|
||
Tag::parse(["t", "read-state"]).expect("t tag"),
|
||
])
|
||
.custom_created_at(Timestamp::from(created_at))
|
||
.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
|
||
);
|
||
let old_id = old_head.id.as_bytes().to_vec();
|
||
let new_id = vec![0x5a_u8; 32];
|
||
|
||
// The replacement: insert a newer head (still at or before the
|
||
// tombstone), hard-delete the old head, and hold the transaction open.
|
||
let mut replacement = db.pool.begin().await.expect("begin replacement");
|
||
sqlx::query("SELECT set_config('buzz.nip_rs_hard_delete', 'on', true)")
|
||
.execute(&mut *replacement)
|
||
.await
|
||
.expect("opt in to hard delete");
|
||
sqlx::query(
|
||
"INSERT INTO events \
|
||
(community_id, id, pubkey, created_at, kind, tags, content, sig, channel_id, d_tag) \
|
||
SELECT community_id, $3, pubkey, created_at + interval '1 second', kind, tags, \
|
||
'new head', sig, channel_id, d_tag \
|
||
FROM events WHERE community_id = $1 AND id = $2",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(&old_id)
|
||
.bind(&new_id)
|
||
.execute(&mut *replacement)
|
||
.await
|
||
.expect("insert new head");
|
||
sqlx::query("DELETE FROM events WHERE community_id = $1 AND id = $2")
|
||
.bind(community.as_uuid())
|
||
.bind(&old_id)
|
||
.execute(&mut *replacement)
|
||
.await
|
||
.expect("delete old head");
|
||
|
||
let database_url =
|
||
std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into());
|
||
let application_name = format!("nip-rs-race-{}", Uuid::new_v4().simple());
|
||
let deleter_pool = PgPoolOptions::new()
|
||
.max_connections(1)
|
||
.connect_with(
|
||
PgConnectOptions::from_str(&database_url)
|
||
.expect("parse test database URL")
|
||
.application_name(&application_name),
|
||
)
|
||
.await
|
||
.expect("connect deleter pool");
|
||
let pubkey = keys.public_key().to_bytes();
|
||
let deletion = tokio::spawn({
|
||
let d_tag = d_tag.clone();
|
||
async move {
|
||
event::soft_delete_by_coordinate(
|
||
&deleter_pool,
|
||
community,
|
||
buzz_core::kind::KIND_READ_STATE as i32,
|
||
&pubkey,
|
||
&d_tag,
|
||
(created_at + 5) as i64,
|
||
)
|
||
.await
|
||
}
|
||
});
|
||
|
||
tokio::time::timeout(Duration::from_secs(10), async {
|
||
loop {
|
||
let waiting: bool = sqlx::query_scalar(
|
||
"SELECT EXISTS(SELECT 1 FROM pg_stat_activity \
|
||
WHERE datname = current_database() AND application_name = $1 \
|
||
AND wait_event_type = 'Lock')",
|
||
)
|
||
.bind(&application_name)
|
||
.fetch_one(&db.pool)
|
||
.await
|
||
.expect("inspect deleter lock");
|
||
if waiting {
|
||
break;
|
||
}
|
||
tokio::task::yield_now().await;
|
||
}
|
||
})
|
||
.await
|
||
.expect("deletion reached the old head's row lock");
|
||
|
||
replacement.commit().await.expect("commit replacement");
|
||
deletion
|
||
.await
|
||
.expect("join deletion")
|
||
.expect("coordinate delete");
|
||
|
||
let rows: Vec<(Vec<u8>, bool)> = sqlx::query_as(
|
||
"SELECT id, deleted_at IS NOT NULL FROM events \
|
||
WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4",
|
||
)
|
||
.bind(community.as_uuid())
|
||
.bind(buzz_core::kind::KIND_READ_STATE as i32)
|
||
.bind(keys.public_key().to_bytes().as_slice())
|
||
.bind(&d_tag)
|
||
.fetch_all(&db.pool)
|
||
.await
|
||
.expect("read coordinate rows");
|
||
assert_eq!(
|
||
rows,
|
||
vec![(new_id, false)],
|
||
"the racing head is spared and no soft-deleted read-state row remains"
|
||
);
|
||
}
|
||
}
|