mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(push): deliver accepted relay events as wakes (#1866)
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
parent
8b2dd6d51a
commit
bffbc5f22c
@@ -951,6 +951,116 @@ impl Db {
|
||||
event::get_events_by_ids(&self.pool, community_id, ids).await
|
||||
}
|
||||
|
||||
/// Exclusively claim the next due event-to-push matcher job.
|
||||
pub async fn claim_due_push_match(
|
||||
&self,
|
||||
lease_until: DateTime<Utc>,
|
||||
) -> Result<Option<push::ClaimedMatch>> {
|
||||
push::claim_due_match(&self.pool, lease_until).await
|
||||
}
|
||||
|
||||
/// Load active endpoint-enabled leases eligible for push matching.
|
||||
pub async fn active_push_match_leases(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
) -> Result<Vec<push::MatchLease>> {
|
||||
push::active_match_leases(&self.pool, community).await
|
||||
}
|
||||
|
||||
/// Complete a matcher job if its claim fence is still held.
|
||||
pub async fn complete_push_match(&self, job: &push::ClaimedMatch) -> Result<bool> {
|
||||
push::complete_match(&self.pool, job).await
|
||||
}
|
||||
|
||||
/// Release a matcher claim for retry at the supplied time.
|
||||
pub async fn retry_push_match(
|
||||
&self,
|
||||
job: &push::ClaimedMatch,
|
||||
next: DateTime<Utc>,
|
||||
) -> Result<bool> {
|
||||
push::retry_match(&self.pool, job, next).await
|
||||
}
|
||||
|
||||
/// Idempotently enqueue a wake for a matched lease and event.
|
||||
pub async fn enqueue_push_wake(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
author: &[u8],
|
||||
installation_id: &str,
|
||||
wake: push::NewWake<'_>,
|
||||
) -> Result<push::EnqueueWakeOutcome> {
|
||||
push::enqueue_wake(&self.pool, community, author, installation_id, wake).await
|
||||
}
|
||||
|
||||
/// Exclusively claim due wake jobs for one community.
|
||||
pub async fn claim_due_push_wakes(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
limit: i64,
|
||||
lease_until: DateTime<Utc>,
|
||||
) -> Result<Vec<push::ClaimedWake>> {
|
||||
push::claim_due_wakes(&self.pool, community, limit, lease_until).await
|
||||
}
|
||||
|
||||
/// Revalidate a wake's claim, source event, and current lease before send.
|
||||
pub async fn revalidate_push_wake(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
id: Uuid,
|
||||
claim_id: Uuid,
|
||||
) -> Result<push::RevalidateWakeOutcome> {
|
||||
push::revalidate_wake_for_send(&self.pool, community, id, claim_id).await
|
||||
}
|
||||
|
||||
/// Mark a fenced wake claim delivered.
|
||||
pub async fn complete_push_wake(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
id: Uuid,
|
||||
claim_id: Uuid,
|
||||
) -> Result<bool> {
|
||||
push::complete_wake(&self.pool, community, id, claim_id).await
|
||||
}
|
||||
|
||||
/// Release a fenced wake claim for retry at the supplied time.
|
||||
pub async fn retry_push_wake(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
id: Uuid,
|
||||
claim_id: Uuid,
|
||||
next: DateTime<Utc>,
|
||||
) -> Result<bool> {
|
||||
push::retry_wake(&self.pool, community, id, claim_id, next).await
|
||||
}
|
||||
|
||||
/// Mark a fenced wake claim terminally failed.
|
||||
pub async fn fail_push_wake(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
id: Uuid,
|
||||
claim_id: Uuid,
|
||||
) -> Result<bool> {
|
||||
push::fail_wake(&self.pool, community, id, claim_id).await
|
||||
}
|
||||
|
||||
/// Disable an endpoint only if the specified lease generation is current.
|
||||
pub async fn disable_push_endpoint(
|
||||
&self,
|
||||
community: CommunityId,
|
||||
author: &[u8],
|
||||
installation_id: &str,
|
||||
generation: i64,
|
||||
) -> Result<bool> {
|
||||
push::disable_endpoint_generation(
|
||||
&self.pool,
|
||||
community,
|
||||
author,
|
||||
installation_id,
|
||||
generation,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Atomically persist a validated kind:30350 event and its effective lease.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn accept_push_lease_event(
|
||||
|
||||
@@ -549,7 +549,7 @@ mod tests {
|
||||
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
|
||||
migrations.sort_by_key(|migration| migration.version);
|
||||
|
||||
assert_eq!(migrations.len(), 17);
|
||||
assert_eq!(migrations.len(), 18);
|
||||
assert_eq!(migrations[0].version, 1);
|
||||
assert_eq!(&*migrations[0].description, "initial schema");
|
||||
assert!(migrations[0]
|
||||
@@ -765,6 +765,8 @@ mod tests {
|
||||
.as_str()
|
||||
.contains("_operator_global_tables"));
|
||||
|
||||
// Community archival and product feedback landed concurrently. Keep
|
||||
// both additive migrations in a single, unambiguous sequence.
|
||||
assert_eq!(migrations[15].version, 16);
|
||||
assert!(migrations[15]
|
||||
.sql
|
||||
@@ -787,6 +789,15 @@ mod tests {
|
||||
.as_str()
|
||||
.contains("('product_feedback', 'deployment product inbox"));
|
||||
assert!(!migrations[0].sql.as_str().contains("product_feedback"));
|
||||
|
||||
// Matching is driven from a parent-table trigger so all partition and
|
||||
// internal insertion paths share the same crash-safe allowlist seam.
|
||||
assert_eq!(migrations[17].version, 18);
|
||||
let matcher = migrations[17].sql.as_str();
|
||||
assert!(matcher.contains("CREATE TABLE push_match_queue"));
|
||||
assert!(matcher.contains("AFTER INSERT ON events"));
|
||||
assert!(matcher.contains("NEW.kind IN (7, 9, 1059, 40007, 46010)"));
|
||||
assert!(!migrations[0].sql.as_str().contains("push_match_queue"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1029,7 +1040,7 @@ mod tests {
|
||||
run_migrations(&pool)
|
||||
.await
|
||||
.expect("retry succeeds after operator repair");
|
||||
assert_eq!(applied_versions(&pool).await.last().copied(), Some(17));
|
||||
assert_eq!(applied_versions(&pool).await.last().copied(), Some(18));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+449
-15
@@ -12,6 +12,9 @@ use uuid::Uuid;
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
/// Maximum claims for a malformed matcher job before it is discarded.
|
||||
pub const MAX_MATCH_ATTEMPTS: i32 = 8;
|
||||
|
||||
/// Common signed-event ordering fields for a lease replacement.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LeaseVersion<'a> {
|
||||
@@ -78,10 +81,16 @@ pub struct NewWake<'a> {
|
||||
/// One exclusively claimed wake, already revalidated against its current lease.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ClaimedWake {
|
||||
/// Server-resolved tenant that owns this wake.
|
||||
pub community: CommunityId,
|
||||
/// Durable job id; this is also the stable gateway/APNs request id.
|
||||
pub id: Uuid,
|
||||
/// Claim fencing token required by every completion operation.
|
||||
pub claim_id: Uuid,
|
||||
/// Accepted event that caused the wake.
|
||||
pub event_id: Vec<u8>,
|
||||
/// Event channel used for send-time authorization revalidation.
|
||||
pub channel_id: Option<Uuid>,
|
||||
/// Lease author whose read authorization must be rechecked by the relay.
|
||||
pub author: Vec<u8>,
|
||||
/// Installation address within the community.
|
||||
@@ -102,11 +111,39 @@ pub struct ClaimedWake {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum RevalidateWakeOutcome {
|
||||
/// The claim and current lease still authorize delivery.
|
||||
Deliver(ClaimedWake),
|
||||
Deliver(Box<ClaimedWake>),
|
||||
/// The claim was lost or the lease rotated, revoked, expired, or disabled.
|
||||
Suppressed,
|
||||
}
|
||||
|
||||
/// One durably accepted event claimed for push matching.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClaimedMatch {
|
||||
/// Tenant that owns both the event and matcher job.
|
||||
pub community: CommunityId,
|
||||
/// Non-deleted source event loaded after the claim commits.
|
||||
pub event: buzz_core::StoredEvent,
|
||||
/// Fencing token required to complete or retry this claim.
|
||||
pub claim_id: Uuid,
|
||||
/// Attempt number, starting at one for the first claim.
|
||||
pub attempt: i32,
|
||||
}
|
||||
|
||||
/// Current active lease candidate for matcher evaluation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MatchLease {
|
||||
/// Lease owner's raw public key.
|
||||
pub author: Vec<u8>,
|
||||
/// Installation address within the tenant.
|
||||
pub installation_id: String,
|
||||
/// Monotonic generation captured into any resulting wake.
|
||||
pub generation: i64,
|
||||
/// Validated restricted subscription array.
|
||||
pub subscriptions: Value,
|
||||
/// Lease expiry as a Unix timestamp.
|
||||
pub expires_at: i64,
|
||||
}
|
||||
|
||||
/// Result of atomically accepting a signed push lease and its effective state.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AcceptLeaseOutcome {
|
||||
@@ -538,6 +575,139 @@ pub async fn enqueue_wake(
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Exclusively claim the next due matcher job and load its non-deleted event.
|
||||
pub async fn claim_due_match(
|
||||
pool: &PgPool,
|
||||
lease_until: DateTime<Utc>,
|
||||
) -> Result<Option<ClaimedMatch>> {
|
||||
claim_due_match_with_loader(pool, lease_until, |pool, community, event_id| async move {
|
||||
Ok(
|
||||
crate::event::get_events_by_ids(&pool, community, &[&event_id])
|
||||
.await?
|
||||
.into_iter()
|
||||
.next(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn claim_due_match_with_loader<F, Fut>(
|
||||
pool: &PgPool,
|
||||
lease_until: DateTime<Utc>,
|
||||
load: F,
|
||||
) -> Result<Option<ClaimedMatch>>
|
||||
where
|
||||
F: FnOnce(PgPool, CommunityId, Vec<u8>) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<Option<buzz_core::StoredEvent>>>,
|
||||
{
|
||||
let claim_id = Uuid::new_v4();
|
||||
let mut tx = pool.begin().await?;
|
||||
// Reap poison jobs before claiming so a worker crash on the final attempt
|
||||
// cannot leave an unclaimable row pinning outbox retention forever.
|
||||
sqlx::query(
|
||||
"DELETE FROM push_match_queue WHERE attempts >= $1 \
|
||||
AND (state='pending' OR (state='matching' AND lease_until < now()))",
|
||||
)
|
||||
.bind(MAX_MATCH_ATTEMPTS)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
WITH candidate AS (
|
||||
SELECT community_id, event_id
|
||||
FROM push_match_queue
|
||||
WHERE attempts < $3
|
||||
AND next_attempt_at <= now()
|
||||
AND (state = 'pending' OR (state = 'matching' AND lease_until < now()))
|
||||
ORDER BY next_attempt_at, created_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE push_match_queue q
|
||||
SET state='matching', claim_id=$1, lease_until=$2, attempts=attempts+1
|
||||
FROM candidate c
|
||||
WHERE q.community_id=c.community_id AND q.event_id=c.event_id
|
||||
RETURNING q.community_id, q.event_id, q.attempts
|
||||
"#,
|
||||
)
|
||||
.bind(claim_id)
|
||||
.bind(lease_until)
|
||||
.bind(MAX_MATCH_ATTEMPTS)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
tx.commit().await?;
|
||||
return Ok(None);
|
||||
};
|
||||
let community = CommunityId::from_uuid(row.try_get("community_id")?);
|
||||
let event_id: Vec<u8> = row.try_get("event_id")?;
|
||||
let attempt: i32 = row.try_get("attempts")?;
|
||||
tx.commit().await?;
|
||||
let event = load(pool.clone(), community, event_id.clone()).await?;
|
||||
let Some(event) = event else {
|
||||
// Source absence and soft deletion are deliberate privacy-preserving
|
||||
// terminal outcomes. Query errors above propagate instead, leaving the
|
||||
// fenced job recoverable after its claim lease expires.
|
||||
sqlx::query(
|
||||
"DELETE FROM push_match_queue \
|
||||
WHERE community_id=$1 AND event_id=$2 AND claim_id=$3 AND state='matching'",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(&event_id)
|
||||
.bind(claim_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(ClaimedMatch {
|
||||
community,
|
||||
event,
|
||||
claim_id,
|
||||
attempt,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Load active endpoint-enabled leases for one tenant.
|
||||
pub async fn active_match_leases(pool: &PgPool, community: CommunityId) -> Result<Vec<MatchLease>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT author, installation_id, generation, subscriptions, expires_at \
|
||||
FROM push_leases WHERE community_id=$1 AND active AND endpoint_enabled \
|
||||
AND expires_at > EXTRACT(EPOCH FROM now())::bigint",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
Ok(MatchLease {
|
||||
author: row.try_get("author")?,
|
||||
installation_id: row.try_get("installation_id")?,
|
||||
generation: row.try_get("generation")?,
|
||||
subscriptions: row.try_get("subscriptions")?,
|
||||
expires_at: row.try_get("expires_at")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Delete a matcher job only while its claim fence is held.
|
||||
pub async fn complete_match(pool: &PgPool, match_job: &ClaimedMatch) -> Result<bool> {
|
||||
Ok(sqlx::query("DELETE FROM push_match_queue WHERE community_id=$1 AND event_id=$2 AND claim_id=$3 AND state='matching'")
|
||||
.bind(match_job.community.as_uuid()).bind(match_job.event.event.id.as_bytes().as_slice())
|
||||
.bind(match_job.claim_id).execute(pool).await?.rows_affected() == 1)
|
||||
}
|
||||
|
||||
/// Release a fenced matcher claim for retry at the supplied time.
|
||||
pub async fn retry_match(
|
||||
pool: &PgPool,
|
||||
match_job: &ClaimedMatch,
|
||||
next: DateTime<Utc>,
|
||||
) -> Result<bool> {
|
||||
Ok(sqlx::query("UPDATE push_match_queue SET state='pending', claim_id=NULL, lease_until=NULL, next_attempt_at=$4 WHERE community_id=$1 AND event_id=$2 AND claim_id=$3 AND state='matching'")
|
||||
.bind(match_job.community.as_uuid()).bind(match_job.event.event.id.as_bytes().as_slice())
|
||||
.bind(match_job.claim_id).bind(next).execute(pool).await?.rows_affected() == 1)
|
||||
}
|
||||
|
||||
/// Claim due jobs for one community, recovering expired worker leases.
|
||||
///
|
||||
/// Claiming performs an early lease check, but callers MUST invoke
|
||||
@@ -552,7 +722,7 @@ pub async fn claim_due_wakes(
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
WITH candidates AS (
|
||||
SELECT o.id
|
||||
SELECT o.id, e.channel_id
|
||||
FROM push_wake_outbox o
|
||||
JOIN push_leases l
|
||||
ON l.community_id = o.community_id
|
||||
@@ -560,7 +730,12 @@ pub async fn claim_due_wakes(
|
||||
AND l.installation_id = o.installation_id
|
||||
AND l.generation = o.lease_generation
|
||||
AND l.endpoint_hash = o.endpoint_hash
|
||||
LEFT JOIN events e
|
||||
ON e.community_id = o.community_id
|
||||
AND e.id = o.event_id
|
||||
AND e.deleted_at IS NULL
|
||||
WHERE o.community_id = $1
|
||||
AND e.id IS NOT NULL
|
||||
AND o.expires_at > EXTRACT(EPOCH FROM now())::bigint
|
||||
AND o.next_attempt_at <= now()
|
||||
AND (o.state = 'pending' OR (o.state = 'sending' AND o.lease_until < now()))
|
||||
@@ -581,9 +756,9 @@ pub async fn claim_due_wakes(
|
||||
AND l.installation_id = o.installation_id
|
||||
AND l.generation = o.lease_generation
|
||||
AND l.endpoint_hash = o.endpoint_hash
|
||||
RETURNING o.id, o.claim_id, o.author, o.installation_id,
|
||||
o.lease_generation, l.endpoint_grant, o.class,
|
||||
o.expires_at, o.attempts
|
||||
RETURNING o.community_id, o.id, o.claim_id, o.event_id, c.channel_id,
|
||||
o.author, o.installation_id, o.lease_generation,
|
||||
l.endpoint_grant, o.class, o.expires_at, o.attempts
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
@@ -609,9 +784,9 @@ pub async fn revalidate_wake_for_send(
|
||||
) -> Result<RevalidateWakeOutcome> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT o.id, o.claim_id, o.author, o.installation_id,
|
||||
o.lease_generation, l.endpoint_grant, o.class,
|
||||
o.expires_at, o.attempts
|
||||
SELECT o.community_id, o.id, o.claim_id, o.event_id, e.channel_id,
|
||||
o.author, o.installation_id, o.lease_generation,
|
||||
l.endpoint_grant, o.class, o.expires_at, o.attempts
|
||||
FROM push_wake_outbox o
|
||||
JOIN push_leases l
|
||||
ON l.community_id = o.community_id
|
||||
@@ -619,6 +794,10 @@ pub async fn revalidate_wake_for_send(
|
||||
AND l.installation_id = o.installation_id
|
||||
AND l.generation = o.lease_generation
|
||||
AND l.endpoint_hash = o.endpoint_hash
|
||||
JOIN events e
|
||||
ON e.community_id = o.community_id
|
||||
AND e.id = o.event_id
|
||||
AND e.deleted_at IS NULL
|
||||
WHERE o.community_id = $1
|
||||
AND o.id = $2
|
||||
AND o.claim_id = $3
|
||||
@@ -639,7 +818,7 @@ pub async fn revalidate_wake_for_send(
|
||||
row.map(row_to_claimed_wake)
|
||||
.transpose()?
|
||||
.map_or(Ok(RevalidateWakeOutcome::Suppressed), |wake| {
|
||||
Ok(RevalidateWakeOutcome::Deliver(wake))
|
||||
Ok(RevalidateWakeOutcome::Deliver(Box::new(wake)))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -731,16 +910,24 @@ pub async fn disable_endpoint_generation(
|
||||
}
|
||||
|
||||
/// Delete terminal/expired outbox rows older than a retention cutoff.
|
||||
///
|
||||
/// NIP-RS hard purge only targets kind 30078, which is not push-eligible and
|
||||
/// therefore cannot have a matcher row; any other absent source is handled by
|
||||
/// the matcher's fenced load-miss deletion.
|
||||
pub async fn prune_wake_outbox(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
before: DateTime<Utc>,
|
||||
) -> Result<u64> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM push_wake_outbox \
|
||||
WHERE community_id = $1 AND created_at < $2 \
|
||||
AND (state IN ('delivered', 'failed') \
|
||||
OR expires_at <= EXTRACT(EPOCH FROM now())::bigint)",
|
||||
"DELETE FROM push_wake_outbox o \
|
||||
WHERE o.community_id = $1 AND o.created_at < $2 \
|
||||
AND (o.state IN ('delivered', 'failed') \
|
||||
OR o.expires_at <= EXTRACT(EPOCH FROM now())::bigint) \
|
||||
AND NOT EXISTS ( \
|
||||
SELECT 1 FROM push_match_queue q \
|
||||
WHERE q.community_id = o.community_id AND q.event_id = o.event_id \
|
||||
)",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(before)
|
||||
@@ -751,8 +938,11 @@ pub async fn prune_wake_outbox(
|
||||
|
||||
fn row_to_claimed_wake(row: sqlx::postgres::PgRow) -> Result<ClaimedWake> {
|
||||
Ok(ClaimedWake {
|
||||
community: CommunityId::from_uuid(row.try_get("community_id")?),
|
||||
id: row.try_get("id")?,
|
||||
claim_id: row.try_get("claim_id")?,
|
||||
event_id: row.try_get("event_id")?,
|
||||
channel_id: row.try_get("channel_id")?,
|
||||
author: row.try_get("author")?,
|
||||
installation_id: row.try_get("installation_id")?,
|
||||
lease_generation: row.try_get("lease_generation")?,
|
||||
@@ -1090,9 +1280,20 @@ mod tests {
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
author: &[u8],
|
||||
event: &[u8],
|
||||
event_id: &[u8; 32],
|
||||
generation: i64,
|
||||
) -> Uuid {
|
||||
sqlx::query(
|
||||
"INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig) \
|
||||
VALUES ($1, $2, $3, to_timestamp(1), 9, '[]', '', $4)",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(event_id)
|
||||
.bind([42_u8; 32])
|
||||
.bind([43_u8; 64])
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert wake source event");
|
||||
match enqueue_wake(
|
||||
pool,
|
||||
community,
|
||||
@@ -1100,7 +1301,7 @@ mod tests {
|
||||
"install",
|
||||
NewWake {
|
||||
lease_generation: generation,
|
||||
event_id: event,
|
||||
event_id,
|
||||
class: "default",
|
||||
expires_at: i64::MAX / 2,
|
||||
},
|
||||
@@ -1261,4 +1462,237 @@ mod tests {
|
||||
EnqueueWakeOutcome::Enqueued(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn matcher_trigger_is_allowlisted_and_deleted_events_are_discarded() {
|
||||
let pool = setup_pool().await;
|
||||
let community = make_community(&pool).await;
|
||||
let keys = nostr::Keys::generate();
|
||||
let push_event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "push")
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign push event");
|
||||
let read_state = nostr::EventBuilder::new(nostr::Kind::Custom(30_078), "read")
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign read state");
|
||||
crate::event::insert_event(&pool, community, &push_event, None)
|
||||
.await
|
||||
.expect("insert push event");
|
||||
crate::event::insert_event(&pool, community, &read_state, None)
|
||||
.await
|
||||
.expect("insert non-push event");
|
||||
|
||||
let queued: Vec<i32> = sqlx::query_scalar(
|
||||
"SELECT e.kind FROM push_match_queue q JOIN events e \
|
||||
ON e.community_id=q.community_id AND e.id=q.event_id \
|
||||
WHERE q.community_id=$1",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.expect("read matcher queue");
|
||||
assert_eq!(queued, vec![9]);
|
||||
|
||||
sqlx::query("UPDATE events SET deleted_at=now() WHERE community_id=$1 AND id=$2")
|
||||
.bind(community.as_uuid())
|
||||
.bind(push_event.id.as_bytes().as_slice())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("soft delete before matching");
|
||||
assert!(
|
||||
claim_due_match(&pool, Utc::now() + chrono::Duration::minutes(1))
|
||||
.await
|
||||
.expect("claim deleted event")
|
||||
.is_none()
|
||||
);
|
||||
let remaining: i64 =
|
||||
sqlx::query_scalar("SELECT count(*) FROM push_match_queue WHERE community_id=$1")
|
||||
.bind(community.as_uuid())
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("count discarded job");
|
||||
assert_eq!(remaining, 0, "deleted content must never produce a wake");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn matcher_load_error_preserves_claimed_job_for_recovery() {
|
||||
let pool = setup_pool().await;
|
||||
let community = make_community(&pool).await;
|
||||
let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "retry me")
|
||||
.sign_with_keys(&nostr::Keys::generate())
|
||||
.expect("sign event");
|
||||
crate::event::insert_event(&pool, community, &event, None)
|
||||
.await
|
||||
.expect("insert event");
|
||||
|
||||
let error = claim_due_match_with_loader(
|
||||
&pool,
|
||||
Utc::now() - chrono::Duration::seconds(1),
|
||||
|_pool, _community, _event_id| async {
|
||||
Err(crate::DbError::InvalidData("injected load failure".into()))
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("load error must propagate");
|
||||
assert!(error.to_string().contains("injected load failure"));
|
||||
let row: (String, i32) = sqlx::query_as(
|
||||
"SELECT state, attempts FROM push_match_queue WHERE community_id=$1 AND event_id=$2",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(event.id.as_bytes().as_slice())
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("load failure must preserve matcher row");
|
||||
assert_eq!(row, ("matching".to_string(), 1));
|
||||
assert!(
|
||||
claim_due_match(&pool, Utc::now() + chrono::Duration::minutes(1))
|
||||
.await
|
||||
.expect("expired claim remains recoverable")
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn matcher_claim_is_exclusive_across_workers() {
|
||||
let pool = setup_pool().await;
|
||||
let community = make_community(&pool).await;
|
||||
let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "one job")
|
||||
.sign_with_keys(&nostr::Keys::generate())
|
||||
.expect("sign event");
|
||||
crate::event::insert_event(&pool, community, &event, None)
|
||||
.await
|
||||
.expect("insert event");
|
||||
let barrier = Arc::new(Barrier::new(8));
|
||||
let mut tasks = Vec::new();
|
||||
for _ in 0..8 {
|
||||
let pool = pool.clone();
|
||||
let barrier = Arc::clone(&barrier);
|
||||
tasks.push(tokio::spawn(async move {
|
||||
barrier.wait().await;
|
||||
claim_due_match(&pool, Utc::now() + chrono::Duration::minutes(1))
|
||||
.await
|
||||
.expect("claim matcher job")
|
||||
}));
|
||||
}
|
||||
let mut claimed = 0;
|
||||
for task in tasks {
|
||||
claimed += usize::from(task.await.expect("join").is_some());
|
||||
}
|
||||
assert_eq!(claimed, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn delivered_wake_is_retained_while_rematch_is_queued() {
|
||||
let pool = setup_pool().await;
|
||||
let community = make_community(&pool).await;
|
||||
let author = [22; 32];
|
||||
let event_id = [23; 32];
|
||||
activate(&pool, community, &author, "install", &[24; 32], 1).await;
|
||||
let wake_id = enqueue_one(&pool, community, &author, &event_id, 1).await;
|
||||
sqlx::query(
|
||||
"UPDATE push_wake_outbox SET state='delivered', created_at=now()-interval '2 days' \
|
||||
WHERE community_id=$1 AND id=$2",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(wake_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("mark old wake delivered");
|
||||
|
||||
let cutoff = Utc::now() - chrono::Duration::days(1);
|
||||
assert_eq!(
|
||||
prune_wake_outbox(&pool, community, cutoff).await.unwrap(),
|
||||
0
|
||||
);
|
||||
sqlx::query("DELETE FROM push_match_queue WHERE community_id=$1 AND event_id=$2")
|
||||
.bind(community.as_uuid())
|
||||
.bind(event_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("complete rematch");
|
||||
assert_eq!(
|
||||
prune_wake_outbox(&pool, community, cutoff).await.unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn exhausted_match_job_is_reaped_and_cannot_pin_retention() {
|
||||
let pool = setup_pool().await;
|
||||
let community = make_community(&pool).await;
|
||||
let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "poison")
|
||||
.sign_with_keys(&nostr::Keys::generate())
|
||||
.expect("sign event");
|
||||
crate::event::insert_event(&pool, community, &event, None)
|
||||
.await
|
||||
.expect("insert event");
|
||||
let author = [25; 32];
|
||||
activate(&pool, community, &author, "install", &[26; 32], 1).await;
|
||||
let wake_id = match enqueue_wake(
|
||||
&pool,
|
||||
community,
|
||||
&author,
|
||||
"install",
|
||||
NewWake {
|
||||
lease_generation: 1,
|
||||
event_id: event.id.as_bytes(),
|
||||
class: "default",
|
||||
expires_at: i64::MAX / 2,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("enqueue wake")
|
||||
{
|
||||
EnqueueWakeOutcome::Enqueued(id) => id,
|
||||
other => panic!("expected fresh wake, got {other:?}"),
|
||||
};
|
||||
sqlx::query(
|
||||
"UPDATE push_wake_outbox SET state='delivered', created_at=now()-interval '2 days' \
|
||||
WHERE community_id=$1 AND id=$2",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(wake_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("mark old wake delivered");
|
||||
let cutoff = Utc::now() - chrono::Duration::days(1);
|
||||
assert_eq!(
|
||||
prune_wake_outbox(&pool, community, cutoff).await.unwrap(),
|
||||
0
|
||||
);
|
||||
sqlx::query(
|
||||
"UPDATE push_match_queue SET attempts=$3, state='matching', lease_until=now()-interval '1 second' \
|
||||
WHERE community_id=$1 AND event_id=$2",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(event.id.as_bytes().as_slice())
|
||||
.bind(MAX_MATCH_ATTEMPTS)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("exhaust matcher job");
|
||||
assert!(
|
||||
claim_due_match(&pool, Utc::now() + chrono::Duration::minutes(1))
|
||||
.await
|
||||
.expect("reap exhausted matcher")
|
||||
.is_none()
|
||||
);
|
||||
let remaining: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM push_match_queue WHERE community_id=$1 AND event_id=$2",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(event.id.as_bytes().as_slice())
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(remaining, 0);
|
||||
assert_eq!(
|
||||
prune_wake_outbox(&pool, community, cutoff).await.unwrap(),
|
||||
1,
|
||||
"reaped poison job must release delivered-wake retention"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ redis = { workspace = true }
|
||||
# main(). Mirrors buzz-acp's rustls setup for wss://.
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
sqlx = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
base64 = "0.22"
|
||||
buzz-sdk = { workspace = true }
|
||||
buzz-workflow = { workspace = true, features = ["reqwest"] }
|
||||
|
||||
@@ -216,6 +216,8 @@ fn parse_operator_api_origin(raw: &str) -> Result<String, ConfigError> {
|
||||
Ok(raw.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
const DEFAULT_PUSH_GATEWAY_DELIVERY_URL: &str = "https://push.buzz.xyz/v1/deliveries/apns";
|
||||
|
||||
fn parse_push_gateway_delivery_url(raw: &str) -> Result<url::Url, ConfigError> {
|
||||
let url = url::Url::parse(raw.trim()).map_err(|e| {
|
||||
ConfigError::InvalidValue(format!(
|
||||
@@ -535,11 +537,13 @@ impl Config {
|
||||
"BUZZ_PUSH_EXECUTOR_KEY_ID must contain 1..=64 bytes".to_string(),
|
||||
));
|
||||
}
|
||||
let push_gateway_delivery_url = std::env::var("BUZZ_PUSH_GATEWAY_DELIVERY_URL")
|
||||
.ok()
|
||||
.filter(|raw| !raw.trim().is_empty())
|
||||
.map(|raw| parse_push_gateway_delivery_url(&raw))
|
||||
.transpose()?;
|
||||
let push_gateway_delivery_url = match std::env::var("BUZZ_PUSH_GATEWAY_DELIVERY_URL") {
|
||||
Ok(raw) if raw.trim().is_empty() => None,
|
||||
Ok(raw) => Some(parse_push_gateway_delivery_url(&raw)?),
|
||||
Err(_) => Some(parse_push_gateway_delivery_url(
|
||||
DEFAULT_PUSH_GATEWAY_DELIVERY_URL,
|
||||
)?),
|
||||
};
|
||||
let push_gateway_timeout_millis = match std::env::var("BUZZ_PUSH_GATEWAY_TIMEOUT_MS") {
|
||||
Ok(raw) => raw
|
||||
.parse::<u64>()
|
||||
@@ -739,6 +743,31 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_gateway_defaults_to_buzz_and_can_be_disabled() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
let previous = std::env::var_os("BUZZ_PUSH_GATEWAY_DELIVERY_URL");
|
||||
std::env::remove_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL");
|
||||
let config = Config::from_env().expect("default config");
|
||||
assert_eq!(
|
||||
config
|
||||
.push_gateway_delivery_url
|
||||
.as_ref()
|
||||
.map(url::Url::as_str),
|
||||
Some(DEFAULT_PUSH_GATEWAY_DELIVERY_URL)
|
||||
);
|
||||
|
||||
std::env::set_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL", "");
|
||||
let config = Config::from_env().expect("disabled push config");
|
||||
assert!(config.push_gateway_delivery_url.is_none());
|
||||
|
||||
if let Some(value) = previous {
|
||||
std::env::set_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL", value);
|
||||
} else {
|
||||
std::env::remove_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_gateway_url_is_exact_and_fail_closed() {
|
||||
assert!(parse_push_gateway_delivery_url("https://push.example/v1/deliveries/apns").is_ok());
|
||||
|
||||
@@ -12,6 +12,9 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Number, Value};
|
||||
use sha2::Digest as _;
|
||||
|
||||
pub(crate) const PUSH_KINDS: &[u64] = &[7, 9, 1059, 40007, 46010];
|
||||
pub(crate) const URGENT_KINDS: &[u64] = &[];
|
||||
|
||||
/// NIP-PL addressable push-lease event kind.
|
||||
pub const KIND_PUSH_LEASE: u32 = 30_350;
|
||||
/// Largest integer represented exactly by interoperable JSON number implementations.
|
||||
@@ -504,8 +507,8 @@ pub async fn accept(
|
||||
},
|
||||
],
|
||||
supported_classes: &["silent", "default", "time_sensitive"],
|
||||
push_kinds: &[7, 9, 1059, 40007, 46010],
|
||||
urgent_kinds: &[],
|
||||
push_kinds: PUSH_KINDS,
|
||||
urgent_kinds: URGENT_KINDS,
|
||||
max_subscriptions: 16,
|
||||
max_kinds: 16,
|
||||
max_authors: 20,
|
||||
@@ -691,6 +694,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_trigger_allowlist_matches_advertised_push_kinds() {
|
||||
let kinds = PUSH_KINDS
|
||||
.iter()
|
||||
.map(u64::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let predicate = format!("NEW.kind IN ({kinds})");
|
||||
let migration = include_str!("../../../../migrations/0018_push_match_queue.sql");
|
||||
assert!(
|
||||
migration.contains(&predicate),
|
||||
"migration trigger must use PUSH_KINDS exactly: {predicate}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_filter_requires_narrowing_and_self_p_tag() {
|
||||
let body = parse_plaintext(r##"{"v":1,"origin":"o","generation":1,"active":true,"app_profile":"p","transport":"apns","endpoint":"token","subscriptions":[{"filter":{"kinds":[9]},"class":"default"}]}"##, 4096).unwrap();
|
||||
|
||||
@@ -29,6 +29,8 @@ pub mod metrics;
|
||||
pub mod nip11;
|
||||
/// NIP-01 client/relay message parsing.
|
||||
pub mod protocol;
|
||||
/// Durable NIP-PL matcher and delivery worker.
|
||||
pub mod push_runtime;
|
||||
/// Axum router construction.
|
||||
pub mod router;
|
||||
/// Shared application state.
|
||||
|
||||
@@ -599,6 +599,17 @@ async fn main() -> anyhow::Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// NIP-PL matcher and worker are enabled as one unit. Lease acceptance is
|
||||
// already disabled without the exact gateway URL, so discovery and runtime
|
||||
// cannot advertise or accumulate work for an undeliverable configuration.
|
||||
if state.config.push_gateway_delivery_url.is_some() {
|
||||
tokio::spawn(buzz_relay::push_runtime::run_matcher(Arc::clone(&state)));
|
||||
tokio::spawn(buzz_relay::push_runtime::run_delivery_worker(Arc::clone(
|
||||
&state,
|
||||
)));
|
||||
info!("NIP-PL push matcher and delivery worker started");
|
||||
}
|
||||
|
||||
// NIP-ER reminder scheduler — polls for due reminders and publishes them
|
||||
// to Redis pub/sub for cross-pod fan-out. Each pod's existing
|
||||
// subscribe_local consumer picks them up and applies the author-only gate.
|
||||
|
||||
@@ -40,6 +40,9 @@ pub struct RelayInfo {
|
||||
/// Draft/extension protocol identifiers supported by this relay.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub supported_extensions: Option<Vec<String>>,
|
||||
/// NIP-PL executor descriptor. Present only when push delivery is configured.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub push: Option<serde_json::Value>,
|
||||
/// URL of the relay software repository.
|
||||
pub software: String,
|
||||
/// Relay software version string.
|
||||
@@ -155,6 +158,7 @@ impl RelayInfo {
|
||||
contact: None,
|
||||
supported_nips,
|
||||
supported_extensions: Some(vec!["nip-er".to_string()]),
|
||||
push: None,
|
||||
software: "https://github.com/block/buzz".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
limitation: Some(relay_limitation(max_message_length)),
|
||||
@@ -176,6 +180,52 @@ pub async fn relay_info_handler(
|
||||
axum::response::Json(nip11_document(&state, raw_host).await)
|
||||
}
|
||||
|
||||
fn push_descriptor(
|
||||
push_configured: bool,
|
||||
relay_url: &str,
|
||||
executor_key_id: &str,
|
||||
relay_keypair: &nostr::Keys,
|
||||
tenant_host: Option<&str>,
|
||||
) -> Option<serde_json::Value> {
|
||||
let host = tenant_host?;
|
||||
push_configured.then_some(())?;
|
||||
let scheme = if relay_url.starts_with("wss://") {
|
||||
"wss"
|
||||
} else {
|
||||
"ws"
|
||||
};
|
||||
Some(serde_json::json!({
|
||||
"origin": format!("{scheme}://{host}"),
|
||||
"keys": [{
|
||||
"id": executor_key_id,
|
||||
"pubkey": relay_keypair.public_key().to_hex(),
|
||||
"current": true
|
||||
}],
|
||||
"app_profiles": [
|
||||
{"id": "buzz-ios-production", "transport": "apns"},
|
||||
{"id": "buzz-ios-sandbox", "transport": "apns"}
|
||||
],
|
||||
"push_kinds": crate::handlers::push_lease::PUSH_KINDS,
|
||||
"urgent_kinds": crate::handlers::push_lease::URGENT_KINDS,
|
||||
"h_grammar": "uuid-v4-lowercase",
|
||||
"class_support": {"apns": ["silent", "default", "time_sensitive"]},
|
||||
"limitation": {
|
||||
"max_lease_ttl": 2592000,
|
||||
"max_leases_per_pubkey": 16,
|
||||
"max_subscriptions_per_lease": 16,
|
||||
"max_kinds": 16,
|
||||
"max_authors": 20,
|
||||
"max_h": 50,
|
||||
"max_tag_values": 20,
|
||||
"max_ignore": 8,
|
||||
"max_content_len": 65536,
|
||||
"max_plaintext_len": 32768,
|
||||
"max_endpoint_len": 4096,
|
||||
"max_string_len": 512
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// Builds the served NIP-11 document for a request arriving on `raw_host`.
|
||||
///
|
||||
/// Centralised so the content-negotiated root handler and the dedicated
|
||||
@@ -185,13 +235,34 @@ pub async fn relay_info_handler(
|
||||
pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &str) -> RelayInfo {
|
||||
let (relay_self, advertise_nip43) = nip11_facts(state);
|
||||
let icon = workspace_icon_for_host(state, raw_host).await;
|
||||
RelayInfo::build(
|
||||
let mut info = RelayInfo::build(
|
||||
relay_self.as_deref(),
|
||||
icon.as_deref(),
|
||||
advertise_nip43,
|
||||
state.config.max_frame_bytes,
|
||||
state.config.pairing_relay_url.as_deref(),
|
||||
)
|
||||
);
|
||||
let tenant_host = if state.config.push_gateway_delivery_url.is_some() {
|
||||
crate::tenant::bind_community(&state.db, raw_host)
|
||||
.await
|
||||
.ok()
|
||||
.map(|tenant| tenant.host().to_owned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(push) = push_descriptor(
|
||||
state.config.push_gateway_delivery_url.is_some(),
|
||||
&state.config.relay_url,
|
||||
&state.config.push_executor_key_id,
|
||||
&state.relay_keypair,
|
||||
tenant_host.as_deref(),
|
||||
) {
|
||||
info.supported_extensions
|
||||
.get_or_insert_default()
|
||||
.push("nip-pl".to_string());
|
||||
info.push = Some(push);
|
||||
}
|
||||
info
|
||||
}
|
||||
|
||||
/// Fetches the workspace icon for the community bound to `raw_host`, as the
|
||||
@@ -267,6 +338,22 @@ const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn push_descriptor_is_gated_by_gateway_configuration_and_tenant_binding() {
|
||||
let keys = nostr::Keys::generate();
|
||||
assert!(
|
||||
push_descriptor(false, "ws://relay", "key", &keys, Some("tenant.example")).is_none()
|
||||
);
|
||||
assert!(push_descriptor(true, "ws://relay", "key", &keys, None).is_none());
|
||||
let descriptor = push_descriptor(true, "ws://relay", "key", &keys, Some("tenant.example"))
|
||||
.expect("configured push descriptor");
|
||||
assert_eq!(descriptor["origin"], "ws://tenant.example");
|
||||
assert_eq!(
|
||||
descriptor["push_kinds"],
|
||||
serde_json::json!(crate::handlers::push_lease::PUSH_KINDS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_nips_includes_nip23_and_nip33() {
|
||||
// Tests the production SUPPORTED_NIPS constant directly — no Config::from_env()
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
//! Durable NIP-PL event matcher and gateway delivery worker.
|
||||
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use base64::Engine as _;
|
||||
use buzz_core::filter::{filters_match, reader_authorized_for_event};
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use nostr::{EventBuilder, Filter, Kind, Tag};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use tracing::{error, warn};
|
||||
|
||||
use crate::{handlers::push_lease::Subscription, state::AppState};
|
||||
|
||||
const CLAIM_SECS: i64 = 30;
|
||||
const EVENT_USEFUL_SECS: i64 = 3600;
|
||||
const MAX_ATTEMPTS: i32 = 8;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DeliveryRequest<'a> {
|
||||
v: u8,
|
||||
endpoint_grant: &'a str,
|
||||
request_id: uuid::Uuid,
|
||||
expires_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "status")]
|
||||
enum DeliveryResponse {
|
||||
Accepted,
|
||||
InvalidEndpoint {
|
||||
generation: i64,
|
||||
invalid_at: Option<i64>,
|
||||
},
|
||||
Retry {
|
||||
retry_after_seconds: Option<i64>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Continuously claim accepted events and match them against active leases.
|
||||
pub async fn run_matcher(state: Arc<AppState>) {
|
||||
loop {
|
||||
let until = Utc::now() + TimeDelta::seconds(CLAIM_SECS);
|
||||
match state.db.claim_due_push_match(until).await {
|
||||
Ok(Some(job)) => {
|
||||
if let Err(e) = process_match(&state, &job).await {
|
||||
warn!(event_id=%job.event.event.id, attempt=job.attempt, "push match failed: {e}");
|
||||
if job.attempt >= buzz_db::push::MAX_MATCH_ATTEMPTS {
|
||||
// A poison event/lease must not retry forever or pin
|
||||
// delivered outbox retention through the rematch guard.
|
||||
let _ = state.db.complete_push_match(&job).await;
|
||||
} else {
|
||||
let _ = state
|
||||
.db
|
||||
.retry_push_match(&job, Utc::now() + TimeDelta::seconds(2))
|
||||
.await;
|
||||
}
|
||||
} else if let Err(e) = state.db.complete_push_match(&job).await {
|
||||
warn!(event_id=%job.event.event.id, "push match completion failed: {e}");
|
||||
}
|
||||
}
|
||||
Ok(None) => tokio::time::sleep(Duration::from_millis(250)).await,
|
||||
Err(e) => {
|
||||
error!("push matcher claim failed: {e}");
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_match(state: &AppState, job: &buzz_db::push::ClaimedMatch) -> anyhow::Result<()> {
|
||||
let leases = state.db.active_push_match_leases(job.community).await?;
|
||||
for lease in leases {
|
||||
let author_hex = hex::encode(&lease.author);
|
||||
if !reader_authorized_for_event(&job.event.event, &author_hex) {
|
||||
continue;
|
||||
}
|
||||
if let Some(channel) = job.event.channel_id {
|
||||
if !state
|
||||
.db
|
||||
.is_member(job.community, channel, &lease.author)
|
||||
.await?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let subscriptions: Vec<Subscription> = serde_json::from_value(lease.subscriptions.clone())?;
|
||||
let mut class: Option<&str> = None;
|
||||
for sub in &subscriptions {
|
||||
let filter: Filter =
|
||||
serde_json::from_value(serde_json::Value::Object(sub.filter.clone()))?;
|
||||
if !push_filter_authorized_for_event(&filter, &job.event.event, &author_hex)
|
||||
|| !filters_match(std::slice::from_ref(&filter), &job.event)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let ignored = sub.ignore.iter().any(|raw| {
|
||||
serde_json::from_value::<Filter>(serde_json::Value::Object(raw.clone()))
|
||||
.is_ok_and(|f| filters_match(&[f], &job.event))
|
||||
});
|
||||
let p_count = job
|
||||
.event
|
||||
.event
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|t| t.kind().to_string() == "p")
|
||||
.count() as u64;
|
||||
if ignored
|
||||
|| sub
|
||||
.suppress
|
||||
.as_ref()
|
||||
.is_some_and(|s| p_count > s.p_tags_max)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if class.is_none_or(|old| class_rank(&sub.class) > class_rank(old)) {
|
||||
class = Some(&sub.class);
|
||||
}
|
||||
}
|
||||
let Some(class) = class else { continue };
|
||||
let event_deadline = job.event.event.created_at.as_secs() as i64 + EVENT_USEFUL_SECS;
|
||||
let expires_at = lease.expires_at.min(event_deadline);
|
||||
if expires_at <= Utc::now().timestamp() {
|
||||
continue;
|
||||
}
|
||||
let _ = state
|
||||
.db
|
||||
.enqueue_push_wake(
|
||||
job.community,
|
||||
&lease.author,
|
||||
&lease.installation_id,
|
||||
buzz_db::push::NewWake {
|
||||
lease_generation: lease.generation,
|
||||
event_id: job.event.event.id.as_bytes(),
|
||||
class,
|
||||
expires_at,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Match-time counterpart of REQ's filter-level `#p` authorization gate.
|
||||
/// Kind 1059 is globally stored and leaks recipient activity through wake
|
||||
/// timing, so a lease may only match gift wraps addressed to its own author.
|
||||
fn push_filter_authorized_for_event(
|
||||
filter: &Filter,
|
||||
event: &nostr::Event,
|
||||
lease_author_hex: &str,
|
||||
) -> bool {
|
||||
if buzz_core::kind::event_kind_u32(event) != buzz_core::kind::KIND_GIFT_WRAP {
|
||||
return true;
|
||||
}
|
||||
let p = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P);
|
||||
filter.generic_tags.get(&p).is_some_and(|values| {
|
||||
!values.is_empty()
|
||||
&& values.iter().all(|value| value == lease_author_hex)
|
||||
&& event
|
||||
.tags
|
||||
.filter(nostr::TagKind::SingleLetter(p))
|
||||
.any(|tag| tag.content() == Some(lease_author_hex))
|
||||
})
|
||||
}
|
||||
|
||||
/// Continuously claim due wakes and deliver them through the push gateway.
|
||||
pub async fn run_delivery_worker(state: Arc<AppState>) {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(state.config.push_gateway_timeout)
|
||||
.build()
|
||||
.expect("push HTTP client");
|
||||
loop {
|
||||
let mut found = false;
|
||||
match state.db.usage_community_hosts().await {
|
||||
Ok(communities) => {
|
||||
for community in communities {
|
||||
let community = buzz_core::CommunityId::from_uuid(community.id);
|
||||
let until = Utc::now() + TimeDelta::seconds(CLAIM_SECS);
|
||||
match state.db.claim_due_push_wakes(community, 16, until).await {
|
||||
Ok(wakes) => {
|
||||
for wake in wakes {
|
||||
found = true;
|
||||
deliver_one(&state, &http, wake).await;
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(%community, "push wake claim failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => error!("push worker community scan failed: {e}"),
|
||||
}
|
||||
if !found {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn deliver_one(
|
||||
state: &AppState,
|
||||
http: &reqwest::Client,
|
||||
claimed: buzz_db::push::ClaimedWake,
|
||||
) {
|
||||
let outcome = match state
|
||||
.db
|
||||
.revalidate_push_wake(claimed.community, claimed.id, claimed.claim_id)
|
||||
.await
|
||||
{
|
||||
Ok(buzz_db::push::RevalidateWakeOutcome::Deliver(wake)) => wake,
|
||||
Ok(buzz_db::push::RevalidateWakeOutcome::Suppressed) => {
|
||||
let _ = state
|
||||
.db
|
||||
.fail_push_wake(claimed.community, claimed.id, claimed.claim_id)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(wake=%claimed.id, "push revalidation failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(channel) = outcome.channel_id {
|
||||
match state
|
||||
.db
|
||||
.is_member(outcome.community, channel, &outcome.author)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
let _ = state
|
||||
.db
|
||||
.fail_push_wake(outcome.community, outcome.id, outcome.claim_id)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(wake=%outcome.id, "push membership revalidation failed: {e}");
|
||||
let _ = state
|
||||
.db
|
||||
.retry_push_wake(
|
||||
outcome.community,
|
||||
outcome.id,
|
||||
outcome.claim_id,
|
||||
Utc::now() + TimeDelta::seconds(2),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Membership I/O above can race lease replacement. Re-run the generation
|
||||
// fence as the final database operation before transport.
|
||||
let outcome = match state
|
||||
.db
|
||||
.revalidate_push_wake(outcome.community, outcome.id, outcome.claim_id)
|
||||
.await
|
||||
{
|
||||
Ok(buzz_db::push::RevalidateWakeOutcome::Deliver(wake)) => wake,
|
||||
Ok(buzz_db::push::RevalidateWakeOutcome::Suppressed) => {
|
||||
let _ = state
|
||||
.db
|
||||
.fail_push_wake(outcome.community, outcome.id, outcome.claim_id)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(wake=%outcome.id, "final push revalidation failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(url) = state.config.push_gateway_delivery_url.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let body = delivery_body(&outcome.endpoint_grant, outcome.id, outcome.expires_at);
|
||||
let auth = match nip98_header(&state.relay_keypair, url.as_str(), &body) {
|
||||
Ok(auth) => auth,
|
||||
Err(e) => {
|
||||
warn!(wake=%outcome.id, "push auth failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let response = send_gateway_request(http, url, body, auth).await;
|
||||
match response {
|
||||
Ok(r) if r.status().is_success() => match r.json::<DeliveryResponse>().await {
|
||||
Ok(DeliveryResponse::Accepted) => {
|
||||
let _ = state
|
||||
.db
|
||||
.complete_push_wake(outcome.community, outcome.id, outcome.claim_id)
|
||||
.await;
|
||||
}
|
||||
_ => {
|
||||
let _ = state
|
||||
.db
|
||||
.fail_push_wake(outcome.community, outcome.id, outcome.claim_id)
|
||||
.await;
|
||||
}
|
||||
},
|
||||
Ok(r) if r.status() == reqwest::StatusCode::GONE => {
|
||||
match r.json::<DeliveryResponse>().await {
|
||||
Ok(DeliveryResponse::InvalidEndpoint {
|
||||
generation,
|
||||
invalid_at,
|
||||
}) => {
|
||||
if generation == outcome.lease_generation {
|
||||
let _ = state
|
||||
.db
|
||||
.disable_push_endpoint(
|
||||
outcome.community,
|
||||
&outcome.author,
|
||||
&outcome.installation_id,
|
||||
generation,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
warn!(wake=%outcome.id, ?invalid_at, "push endpoint permanently invalid");
|
||||
}
|
||||
_ => warn!(wake=%outcome.id, "invalid closed-protocol 410 response"),
|
||||
}
|
||||
let _ = state
|
||||
.db
|
||||
.fail_push_wake(outcome.community, outcome.id, outcome.claim_id)
|
||||
.await;
|
||||
}
|
||||
Ok(r) if r.status() == reqwest::StatusCode::SERVICE_UNAVAILABLE => {
|
||||
let delay = match r.json::<DeliveryResponse>().await {
|
||||
Ok(DeliveryResponse::Retry {
|
||||
retry_after_seconds,
|
||||
}) => retry_after_seconds
|
||||
.filter(|seconds| *seconds > 0)
|
||||
.unwrap_or(2),
|
||||
_ => 2,
|
||||
};
|
||||
retry_or_fail(state, &outcome, delay).await;
|
||||
}
|
||||
Ok(r) if r.status() == reqwest::StatusCode::TOO_MANY_REQUESTS => {
|
||||
retry_or_fail(state, &outcome, 2).await
|
||||
}
|
||||
// A timed-out terminal attempt burns the stable request id. Its replay
|
||||
// is indistinguishable from another invalid-grant 404, but sending a
|
||||
// fresh id would double-deliver and defeat the gateway replay fence.
|
||||
Ok(r) if r.status() == reqwest::StatusCode::NOT_FOUND && outcome.attempt > 1 => {
|
||||
let _ = state
|
||||
.db
|
||||
.complete_push_wake(outcome.community, outcome.id, outcome.claim_id)
|
||||
.await;
|
||||
}
|
||||
Err(e) if e.is_timeout() || e.is_connect() => retry_or_fail(state, &outcome, 2).await,
|
||||
_ => {
|
||||
let _ = state
|
||||
.db
|
||||
.fail_push_wake(outcome.community, outcome.id, outcome.claim_id)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn delivery_body(endpoint_grant: &str, request_id: uuid::Uuid, expires_at: i64) -> Vec<u8> {
|
||||
serde_json::to_vec(&DeliveryRequest {
|
||||
v: 1,
|
||||
endpoint_grant,
|
||||
request_id,
|
||||
expires_at,
|
||||
})
|
||||
.expect("closed delivery body")
|
||||
}
|
||||
|
||||
async fn send_gateway_request(
|
||||
http: &reqwest::Client,
|
||||
url: &url::Url,
|
||||
body: Vec<u8>,
|
||||
auth: String,
|
||||
) -> reqwest::Result<reqwest::Response> {
|
||||
http.post(url.clone())
|
||||
.header("Authorization", auth)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn retry_or_fail(state: &AppState, wake: &buzz_db::push::ClaimedWake, delay: i64) {
|
||||
if wake.attempt >= MAX_ATTEMPTS {
|
||||
let _ = state
|
||||
.db
|
||||
.fail_push_wake(wake.community, wake.id, wake.claim_id)
|
||||
.await;
|
||||
} else {
|
||||
let secs = delay * (1_i64 << (wake.attempt - 1).clamp(0, 6));
|
||||
let _ = state
|
||||
.db
|
||||
.retry_push_wake(
|
||||
wake.community,
|
||||
wake.id,
|
||||
wake.claim_id,
|
||||
Utc::now() + TimeDelta::seconds(secs),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
fn nip98_header(keys: &nostr::Keys, url: &str, body: &[u8]) -> anyhow::Result<String> {
|
||||
let hash = hex::encode(Sha256::digest(body));
|
||||
let event = EventBuilder::new(Kind::HttpAuth, "")
|
||||
.tags([
|
||||
Tag::parse(["u", url])?,
|
||||
Tag::parse(["method", "POST"])?,
|
||||
Tag::parse(["payload", &hash])?,
|
||||
Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()])?,
|
||||
])
|
||||
.sign_with_keys(keys)?;
|
||||
Ok(format!(
|
||||
"Nostr {}",
|
||||
base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event)?)
|
||||
))
|
||||
}
|
||||
|
||||
fn class_rank(class: &str) -> u8 {
|
||||
match class {
|
||||
"silent" => 0,
|
||||
"default" => 1,
|
||||
"time_sensitive" => 2,
|
||||
"urgent" => 3,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{extract::State, routing::post, Json, Router};
|
||||
use serde_json::Value;
|
||||
use std::{future::IntoFuture, sync::Arc};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[test]
|
||||
fn gift_wrap_match_requires_self_p_filter_and_recipient() {
|
||||
let recipient = nostr::Keys::generate();
|
||||
let other = nostr::Keys::generate();
|
||||
let sender = nostr::Keys::generate();
|
||||
let recipient_hex = recipient.public_key().to_hex();
|
||||
let event = EventBuilder::new(Kind::GiftWrap, "ciphertext")
|
||||
.tag(Tag::public_key(other.public_key()))
|
||||
.sign_with_keys(&sender)
|
||||
.unwrap();
|
||||
let self_filter = Filter::new().pubkey(recipient.public_key());
|
||||
assert!(!push_filter_authorized_for_event(
|
||||
&self_filter,
|
||||
&event,
|
||||
&recipient_hex
|
||||
));
|
||||
|
||||
let event = EventBuilder::new(Kind::GiftWrap, "ciphertext")
|
||||
.tag(Tag::public_key(recipient.public_key()))
|
||||
.sign_with_keys(&sender)
|
||||
.unwrap();
|
||||
assert!(push_filter_authorized_for_event(
|
||||
&self_filter,
|
||||
&event,
|
||||
&recipient_hex
|
||||
));
|
||||
assert!(!push_filter_authorized_for_event(
|
||||
&Filter::new().author(sender.public_key()),
|
||||
&event,
|
||||
&recipient_hex
|
||||
));
|
||||
}
|
||||
|
||||
async fn capture(
|
||||
State(seen): State<Arc<Mutex<Vec<Value>>>>,
|
||||
Json(body): Json<Value>,
|
||||
) -> Json<Value> {
|
||||
seen.lock().await.push(body);
|
||||
Json(serde_json::json!({"status":"accepted"}))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_retries_send_the_same_request_id_over_http() {
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(
|
||||
axum::serve(
|
||||
listener,
|
||||
Router::new()
|
||||
.route("/deliver", post(capture))
|
||||
.with_state(seen.clone()),
|
||||
)
|
||||
.into_future(),
|
||||
);
|
||||
let url: url::Url = format!("http://{address}/deliver").parse().unwrap();
|
||||
let http = reqwest::Client::new();
|
||||
let keys = nostr::Keys::generate();
|
||||
let request_id = uuid::Uuid::new_v4();
|
||||
for _ in 0..2 {
|
||||
let body = delivery_body("opaque-grant", request_id, Utc::now().timestamp() + 60);
|
||||
let auth = nip98_header(&keys, url.as_str(), &body).unwrap();
|
||||
let response = send_gateway_request(&http, &url, body, auth).await.unwrap();
|
||||
assert!(response.status().is_success());
|
||||
}
|
||||
server.abort();
|
||||
let bodies = seen.lock().await;
|
||||
assert_eq!(bodies.len(), 2);
|
||||
assert_eq!(bodies[0]["request_id"], request_id.to_string());
|
||||
assert_eq!(bodies[1]["request_id"], request_id.to_string());
|
||||
}
|
||||
}
|
||||
@@ -72,11 +72,22 @@ Alerting rules ship as an opt-in prometheus-operator `PrometheusRule` (`promethe
|
||||
|
||||
## Relay configuration
|
||||
|
||||
When the follow-up integration lands, each relay will point `BUZZ_PUSH_GATEWAY_DELIVERY_URL` at the same exact public delivery URL. Relays retain lease matching, authorization, coalescing, durable jobs/retries, and generation checks. They receive only opaque capabilities and never APNs tokens or provider credentials.
|
||||
Relays default `BUZZ_PUSH_GATEWAY_DELIVERY_URL` to the exact public delivery URL
|
||||
`https://push.buzz.xyz/v1/deliveries/apns`. Operators can override it with
|
||||
another exact HTTPS `/v1/deliveries/apns` URL, or explicitly disable NIP-PL push
|
||||
by setting the variable to an empty string. When enabled, the relay advertises
|
||||
its host-scoped NIP-PL descriptor in NIP-11 and starts the matcher and delivery
|
||||
worker. Relays retain lease matching, authorization, coalescing, durable
|
||||
jobs/retries, and generation checks; they receive only opaque capabilities and
|
||||
never APNs tokens or provider credentials.
|
||||
|
||||
## Relay integration status
|
||||
|
||||
This PR does **not** enable end-to-end push delivery from a relay. It lands the NIP-PL acceptance and durable lease/outbox primitives plus the independently deployable gateway, but intentionally does not start a relay matcher/worker. The missing client App Attest enrollment/delegation flow must first place a gateway-issued opaque capability—not a raw APNs token—into the encrypted lease. A follow-up must then add per-origin event matching with read-authorization rechecks, durable enqueue, send-time revalidation, and the NIP-98 delivery worker. Operators must leave `BUZZ_PUSH_GATEWAY_DELIVERY_URL` unset until that complete path lands; setting it currently gates lease acceptance only and does not start delivery.
|
||||
The operational relay integration is complete: per-origin event matching with
|
||||
read-authorization checks, durable enqueue, send-time revalidation, and NIP-98
|
||||
delivery run whenever the gateway URL is enabled. End-to-end use still requires
|
||||
the client App Attest enrollment/delegation flow to place a gateway-issued opaque
|
||||
capability—not a raw APNs token—into the encrypted relay lease.
|
||||
|
||||
## Helm production inputs
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Durable event-to-push matching follower. The trigger runs in the event insert
|
||||
-- transaction, so every accepted persistent event has a crash-safe match job and
|
||||
-- rejected/rolled-back events never do. Processing is idempotent through the
|
||||
-- push_wake_outbox endpoint/event unique key.
|
||||
CREATE TABLE push_match_queue (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
event_id BYTEA NOT NULL CHECK (length(event_id) = 32),
|
||||
state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','matching')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
lease_until TIMESTAMPTZ,
|
||||
claim_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, event_id)
|
||||
);
|
||||
CREATE INDEX push_match_queue_due
|
||||
ON push_match_queue (next_attempt_at, created_at) WHERE state = 'pending';
|
||||
CREATE INDEX push_match_queue_recovery
|
||||
ON push_match_queue (lease_until) WHERE state = 'matching';
|
||||
|
||||
CREATE FUNCTION enqueue_push_match_job() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
-- Keep this allowlist identical to the relay's validated NIP-PL descriptor.
|
||||
-- Centralizing it on the events table covers every durable producer,
|
||||
-- including internal paths that bypass live dispatch.
|
||||
IF NEW.kind IN (7, 9, 1059, 40007, 46010) THEN
|
||||
INSERT INTO push_match_queue (community_id, event_id)
|
||||
VALUES (NEW.community_id, NEW.id)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER events_enqueue_push_match
|
||||
AFTER INSERT ON events
|
||||
FOR EACH ROW EXECUTE FUNCTION enqueue_push_match_job();
|
||||
@@ -798,6 +798,45 @@ CREATE INDEX push_wake_outbox_due
|
||||
ON push_wake_outbox (community_id, next_attempt_at) WHERE state = 'pending';
|
||||
CREATE INDEX push_wake_outbox_recovery
|
||||
ON push_wake_outbox (community_id, lease_until) WHERE state = 'sending';
|
||||
-- Durable event-to-push matching follower. The trigger runs in the event insert
|
||||
-- transaction, so every accepted persistent event has a crash-safe match job and
|
||||
-- rejected/rolled-back events never do. Processing is idempotent through the
|
||||
-- push_wake_outbox endpoint/event unique key.
|
||||
CREATE TABLE push_match_queue (
|
||||
community_id UUID NOT NULL REFERENCES communities(id),
|
||||
event_id BYTEA NOT NULL CHECK (length(event_id) = 32),
|
||||
state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','matching')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
lease_until TIMESTAMPTZ,
|
||||
claim_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (community_id, event_id)
|
||||
);
|
||||
CREATE INDEX push_match_queue_due
|
||||
ON push_match_queue (next_attempt_at, created_at) WHERE state = 'pending';
|
||||
CREATE INDEX push_match_queue_recovery
|
||||
ON push_match_queue (lease_until) WHERE state = 'matching';
|
||||
|
||||
CREATE FUNCTION enqueue_push_match_job() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
-- Keep this allowlist identical to the relay's validated NIP-PL descriptor.
|
||||
-- Centralizing it on the events table covers every durable producer,
|
||||
-- including internal paths that bypass live dispatch.
|
||||
IF NEW.kind IN (7, 9, 1059, 40007, 46010) THEN
|
||||
INSERT INTO push_match_queue (community_id, event_id)
|
||||
VALUES (NEW.community_id, NEW.id)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER events_enqueue_push_match
|
||||
AFTER INSERT ON events
|
||||
FOR EACH ROW EXECUTE FUNCTION enqueue_push_match_job();
|
||||
|
||||
-- Durable, deployment-global authority for the public NIP-PL push gateway.
|
||||
-- This state is intentionally outside relay community tenancy: installations
|
||||
-- delegate to relay signing keys and may authorize multiple relay deployments.
|
||||
|
||||
@@ -14,6 +14,10 @@ BEGIN
|
||||
WHERE inhparent = 'events'::regclass
|
||||
AND inhrelid = 'events_p_past'::regclass
|
||||
) THEN
|
||||
-- pgschema may copy the parent trigger onto standalone children. Drop
|
||||
-- that copy before ATTACH; PostgreSQL recreates inherited parent
|
||||
-- triggers while attaching and rejects a same-named child trigger.
|
||||
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p_past;
|
||||
ALTER TABLE events ATTACH PARTITION events_p_past
|
||||
FOR VALUES FROM (MINVALUE) TO ('2026-01-01');
|
||||
END IF;
|
||||
@@ -23,6 +27,7 @@ BEGIN
|
||||
WHERE inhparent = 'events'::regclass
|
||||
AND inhrelid = 'events_p2026_01'::regclass
|
||||
) THEN
|
||||
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p2026_01;
|
||||
ALTER TABLE events ATTACH PARTITION events_p2026_01
|
||||
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
|
||||
END IF;
|
||||
@@ -32,6 +37,7 @@ BEGIN
|
||||
WHERE inhparent = 'events'::regclass
|
||||
AND inhrelid = 'events_p2026_02'::regclass
|
||||
) THEN
|
||||
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p2026_02;
|
||||
ALTER TABLE events ATTACH PARTITION events_p2026_02
|
||||
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
|
||||
END IF;
|
||||
@@ -41,6 +47,7 @@ BEGIN
|
||||
WHERE inhparent = 'events'::regclass
|
||||
AND inhrelid = 'events_p2026_03'::regclass
|
||||
) THEN
|
||||
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p2026_03;
|
||||
ALTER TABLE events ATTACH PARTITION events_p2026_03
|
||||
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
|
||||
END IF;
|
||||
@@ -50,6 +57,7 @@ BEGIN
|
||||
WHERE inhparent = 'events'::regclass
|
||||
AND inhrelid = 'events_p2026_04'::regclass
|
||||
) THEN
|
||||
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p2026_04;
|
||||
ALTER TABLE events ATTACH PARTITION events_p2026_04
|
||||
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
|
||||
END IF;
|
||||
@@ -59,6 +67,7 @@ BEGIN
|
||||
WHERE inhparent = 'events'::regclass
|
||||
AND inhrelid = 'events_p2026_05'::regclass
|
||||
) THEN
|
||||
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p2026_05;
|
||||
ALTER TABLE events ATTACH PARTITION events_p2026_05
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
END IF;
|
||||
@@ -68,6 +77,7 @@ BEGIN
|
||||
WHERE inhparent = 'events'::regclass
|
||||
AND inhrelid = 'events_p2026_06'::regclass
|
||||
) THEN
|
||||
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p2026_06;
|
||||
ALTER TABLE events ATTACH PARTITION events_p2026_06
|
||||
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
END IF;
|
||||
@@ -77,6 +87,7 @@ BEGIN
|
||||
WHERE inhparent = 'events'::regclass
|
||||
AND inhrelid = 'events_p_future'::regclass
|
||||
) THEN
|
||||
DROP TRIGGER IF EXISTS events_enqueue_push_match ON events_p_future;
|
||||
ALTER TABLE events ATTACH PARTITION events_p_future
|
||||
FOR VALUES FROM ('2026-07-01') TO (MAXVALUE);
|
||||
END IF;
|
||||
|
||||
Reference in New Issue
Block a user