fix(db): add tenant-safe query defaults and reaper host provenance

Add EventQuery::for_community so relay call sites can keep concise
struct updates without restoring a tenantless Default. The constructor
requires the server-resolved CommunityId and preserves the old optional
filter defaults everywhere else.

Return the owning community host from the ephemeral-channel reaper by
joining communities in the archive UPDATE. Reaper consumers can now build
TenantContext per archived row from DB-resolved community+host instead of
hoisting or forging a batch-level tenant.

Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
This commit is contained in:
tlongwell-block
2026-06-26 18:59:33 -04:00
co-authored by Mari
parent e55c2ceae0
commit 12df5fb791
2 changed files with 95 additions and 8 deletions
+65 -8
View File
@@ -733,10 +733,12 @@ pub struct BotChannelEntry {
}
/// A channel archived by the ephemeral-channel reaper.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReapedEphemeralChannel {
/// Community that owns the archived channel.
pub community_id: CommunityId,
/// Normalized host mapped to that community.
pub host: String,
/// Archived channel UUID.
pub channel_id: Uuid,
}
@@ -1325,17 +1327,19 @@ pub async fn bump_ttl_deadline(
/// Archive ephemeral channels whose TTL deadline has passed.
///
/// Returns the `(community_id, channel_id)` list that was archived. Idempotent — the
/// Returns the `(community_id, host, channel_id)` list that was archived. Idempotent — the
/// `archived_at IS NULL` guard prevents double-archiving even if called
/// concurrently from multiple relay pods.
pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result<Vec<ReapedEphemeralChannel>> {
let rows = sqlx::query(
"UPDATE channels SET archived_at = NOW() \
WHERE ttl_seconds IS NOT NULL \
AND ttl_deadline < NOW() \
AND archived_at IS NULL \
AND deleted_at IS NULL \
RETURNING community_id, id",
"UPDATE channels AS ch SET archived_at = NOW() \
FROM communities AS c \
WHERE ch.community_id = c.id \
AND ch.ttl_seconds IS NOT NULL \
AND ch.ttl_deadline < NOW() \
AND ch.archived_at IS NULL \
AND ch.deleted_at IS NULL \
RETURNING ch.community_id, c.host, ch.id",
)
.fetch_all(pool)
.await?;
@@ -1343,9 +1347,11 @@ pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result<Vec<Reaped
rows.into_iter()
.map(|row| {
let community_id: Uuid = row.try_get("community_id")?;
let host: String = row.try_get("host")?;
let channel_id: Uuid = row.try_get("id")?;
Ok(ReapedEphemeralChannel {
community_id: CommunityId::from_uuid(community_id),
host,
channel_id,
})
})
@@ -1640,6 +1646,57 @@ mod tests {
);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn reap_expired_ephemeral_channels_returns_row_community_and_host() {
let pool = setup_pool().await;
let community_id = make_test_community(&pool).await;
let community = CommunityId::from_uuid(community_id);
let expected_host: String =
sqlx::query_scalar("SELECT host FROM communities WHERE id = $1")
.bind(community_id)
.fetch_one(&pool)
.await
.expect("load community host");
let owner_pk = random_pubkey();
ensure_user(&pool, community, &owner_pk)
.await
.expect("ensure owner");
let channel = create_test_channel(
&pool,
community_id,
"test-reaper-host-provenance",
ChannelType::Stream,
ChannelVisibility::Open,
None,
&owner_pk,
Some(60),
)
.await
.expect("create ephemeral channel");
sqlx::query(
"UPDATE channels SET ttl_deadline = NOW() - interval '1 second' WHERE community_id = $1 AND id = $2",
)
.bind(community_id)
.bind(channel.id)
.execute(&pool)
.await
.expect("expire channel");
let reaped = reap_expired_ephemeral_channels(&pool)
.await
.expect("run reaper");
assert!(
reaped.iter().any(|row| {
row.community_id == community
&& row.host == expected_host
&& row.channel_id == channel.id
}),
"reaper should carry the archived row's community id and host"
);
}
/// A random non-admin, non-owner user cannot remove someone else's bot.
#[tokio::test]
#[ignore = "requires Postgres"]
+30
View File
@@ -71,6 +71,36 @@ pub struct EventQuery {
pub max_limit: Option<i64>,
}
impl EventQuery {
/// Construct an unconstrained query inside a server-resolved community.
///
/// `community_id` has no safe default. This keeps call sites concise while
/// making tenant provenance explicit at construction.
#[must_use]
pub const fn for_community(community_id: CommunityId) -> Self {
Self {
community_id,
channel_id: None,
kinds: None,
pubkey: None,
since: None,
until: None,
limit: None,
offset: None,
p_tag_hex: None,
d_tag: None,
d_tags: None,
before_id: None,
global_only: false,
authors: None,
ids: None,
e_tags: None,
channel_ids: None,
max_limit: None,
}
}
}
/// Maximum length for a `d_tag` value (bytes). NIP-33 d-tags are short identifiers;
/// anything beyond this is either a bug or abuse.
pub const D_TAG_MAX_LEN: usize = 1024;