mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(relay): enforce atomic community owner quotas
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
parent
a7eca421a5
commit
9c7b83143e
+161
-62
@@ -201,6 +201,19 @@ pub struct EnsuredCommunityRecord {
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
/// Outcome of an atomic create-with-owner operation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CreateCommunityWithOwnerOutcome {
|
||||
/// The community and owner row were inserted by this call.
|
||||
Created(CreatedCommunityRecord),
|
||||
/// The host already existed and was owned by the requested owner.
|
||||
Existing(CreatedCommunityRecord),
|
||||
/// The owner already has the maximum number of active communities.
|
||||
OwnerLimitReached,
|
||||
/// The host already exists but is not owned by the requested owner.
|
||||
HostTaken,
|
||||
}
|
||||
|
||||
/// Community row returned by an atomic create-with-owner operation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreatedCommunityRecord {
|
||||
@@ -466,20 +479,65 @@ impl Db {
|
||||
})
|
||||
}
|
||||
|
||||
/// Atomically creates a community and its initial owner.
|
||||
/// Atomically creates a community and its initial owner under an owner cap.
|
||||
///
|
||||
/// Returns the existing row when the normalized host already has the same
|
||||
/// current owner, making ambiguous create retries naturally idempotent.
|
||||
/// Returns `None` when the host exists with a different (or missing) owner.
|
||||
/// The initial host and owner inserts share one transaction, so callers
|
||||
/// never observe a partially provisioned community or rotate an owner.
|
||||
/// A transaction-scoped advisory lock serializes all creates for one owner
|
||||
/// across relay instances. Existing same-owner hosts are returned before the
|
||||
/// cap check, making ambiguous retries idempotent even at the limit.
|
||||
pub async fn create_community_with_owner(
|
||||
&self,
|
||||
normalized_host: &str,
|
||||
owner_pubkey: &str,
|
||||
) -> Result<Option<CreatedCommunityRecord>> {
|
||||
max_owned_communities: u32,
|
||||
) -> Result<CreateCommunityWithOwnerOutcome> {
|
||||
let owner_pubkey = owner_pubkey.to_ascii_lowercase();
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))")
|
||||
.bind(&owner_pubkey)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let existing = sqlx::query(
|
||||
r#"
|
||||
SELECT c.id, c.host, lower(rm.pubkey) = lower($2) AND rm.role = 'owner' AS owned
|
||||
FROM communities c
|
||||
LEFT JOIN relay_members rm ON rm.community_id = c.id AND rm.role = 'owner'
|
||||
WHERE lower(c.host) = lower($1)
|
||||
"#,
|
||||
)
|
||||
.bind(normalized_host)
|
||||
.bind(&owner_pubkey)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if let Some(existing) = existing {
|
||||
if !existing.try_get::<bool, _>("owned")? {
|
||||
return Ok(CreateCommunityWithOwnerOutcome::HostTaken);
|
||||
}
|
||||
return Ok(CreateCommunityWithOwnerOutcome::Existing(
|
||||
CreatedCommunityRecord {
|
||||
id: CommunityId::from_uuid(existing.try_get("id")?),
|
||||
host: existing.try_get("host")?,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
let owned_count: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT count(*)
|
||||
FROM communities c
|
||||
JOIN relay_members rm ON rm.community_id = c.id
|
||||
WHERE lower(rm.pubkey) = lower($1)
|
||||
AND rm.role = 'owner'
|
||||
"#,
|
||||
)
|
||||
.bind(&owner_pubkey)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if owned_count >= i64::from(max_owned_communities) {
|
||||
return Ok(CreateCommunityWithOwnerOutcome::OwnerLimitReached);
|
||||
}
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO communities (host)
|
||||
@@ -491,45 +549,26 @@ impl Db {
|
||||
.bind(normalized_host)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let (id, host) = if let Some(row) = row {
|
||||
let id: Uuid = row.try_get("id")?;
|
||||
let host: String = row.try_get("host")?;
|
||||
sqlx::query(
|
||||
"INSERT INTO relay_members (community_id, pubkey, role, added_by) VALUES ($1, $2, 'owner', NULL)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&owner_pubkey)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
(id, host)
|
||||
} else {
|
||||
let existing = sqlx::query(
|
||||
r#"
|
||||
SELECT c.id, c.host
|
||||
FROM communities c
|
||||
JOIN relay_members rm ON rm.community_id = c.id
|
||||
WHERE lower(c.host) = lower($1)
|
||||
AND lower(rm.pubkey) = lower($2)
|
||||
AND rm.role = 'owner'
|
||||
"#,
|
||||
)
|
||||
.bind(normalized_host)
|
||||
.bind(&owner_pubkey)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let Some(existing) = existing else {
|
||||
tx.rollback().await?;
|
||||
return Ok(None);
|
||||
};
|
||||
(existing.try_get("id")?, existing.try_get("host")?)
|
||||
let Some(row) = row else {
|
||||
return Ok(CreateCommunityWithOwnerOutcome::HostTaken);
|
||||
};
|
||||
let id: Uuid = row.try_get("id")?;
|
||||
let host: String = row.try_get("host")?;
|
||||
sqlx::query(
|
||||
"INSERT INTO relay_members (community_id, pubkey, role, added_by) VALUES ($1, $2, 'owner', NULL)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&owner_pubkey)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(Some(CreatedCommunityRecord {
|
||||
id: CommunityId::from_uuid(id),
|
||||
host,
|
||||
}))
|
||||
Ok(CreateCommunityWithOwnerOutcome::Created(
|
||||
CreatedCommunityRecord {
|
||||
id: CommunityId::from_uuid(id),
|
||||
host,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Returns the community that owns a channel, if the channel exists.
|
||||
@@ -3009,10 +3048,12 @@ mod tests {
|
||||
let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
let created = db
|
||||
.create_community_with_owner(&host, owner)
|
||||
.create_community_with_owner(&host, owner, u32::MAX)
|
||||
.await
|
||||
.expect("create community")
|
||||
.expect("new host");
|
||||
.expect("create community");
|
||||
let CreateCommunityWithOwnerOutcome::Created(created) = created else {
|
||||
panic!("expected new host");
|
||||
};
|
||||
assert_eq!(created.host, host);
|
||||
let owner_role: Option<String> = sqlx::query_scalar(
|
||||
"SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2",
|
||||
@@ -3025,17 +3066,19 @@ mod tests {
|
||||
assert_eq!(owner_role.as_deref(), Some("owner"));
|
||||
|
||||
let retry = db
|
||||
.create_community_with_owner(&host.to_ascii_uppercase(), owner)
|
||||
.create_community_with_owner(&host.to_ascii_uppercase(), owner, u32::MAX)
|
||||
.await
|
||||
.expect("same-owner retry")
|
||||
.expect("existing same-owner community");
|
||||
.expect("same-owner retry");
|
||||
let CreateCommunityWithOwnerOutcome::Existing(retry) = retry else {
|
||||
panic!("expected existing same-owner community");
|
||||
};
|
||||
assert_eq!(retry, created, "retry returns the original row");
|
||||
|
||||
let collision = db
|
||||
.create_community_with_owner(&host, other)
|
||||
.create_community_with_owner(&host, other, u32::MAX)
|
||||
.await
|
||||
.expect("collision result");
|
||||
assert!(collision.is_none());
|
||||
assert_eq!(collision, CreateCommunityWithOwnerOutcome::HostTaken);
|
||||
let roles: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT pubkey, role FROM relay_members WHERE community_id = $1 ORDER BY pubkey",
|
||||
)
|
||||
@@ -3049,10 +3092,13 @@ mod tests {
|
||||
.await
|
||||
.expect("rotate owner");
|
||||
let post_rotation_retry = db
|
||||
.create_community_with_owner(&host, owner)
|
||||
.create_community_with_owner(&host, owner, u32::MAX)
|
||||
.await
|
||||
.expect("post-rotation retry");
|
||||
assert!(post_rotation_retry.is_none());
|
||||
assert_eq!(
|
||||
post_rotation_retry,
|
||||
CreateCommunityWithOwnerOutcome::HostTaken
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3063,17 +3109,70 @@ mod tests {
|
||||
let owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
|
||||
let (first, second) = tokio::join!(
|
||||
db.create_community_with_owner(&host, owner),
|
||||
db.create_community_with_owner(&host, owner),
|
||||
db.create_community_with_owner(&host, owner, u32::MAX),
|
||||
db.create_community_with_owner(&host, owner, u32::MAX),
|
||||
);
|
||||
let first = first
|
||||
.expect("first concurrent create")
|
||||
.expect("first result");
|
||||
let second = second
|
||||
.expect("second concurrent create")
|
||||
.expect("second result");
|
||||
let first = first.expect("first concurrent create");
|
||||
let second = second.expect("second concurrent create");
|
||||
let records = [first, second].map(|outcome| match outcome {
|
||||
CreateCommunityWithOwnerOutcome::Created(record)
|
||||
| CreateCommunityWithOwnerOutcome::Existing(record) => record,
|
||||
other => panic!("unexpected concurrent outcome: {other:?}"),
|
||||
});
|
||||
|
||||
assert_eq!(first, second, "conflict loser re-reads the winning row");
|
||||
assert_eq!(
|
||||
records[0], records[1],
|
||||
"conflict loser re-reads the winning row"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn concurrent_distinct_creates_cannot_exceed_owner_limit() {
|
||||
let db = setup_db().await;
|
||||
let owner = format!("{:0>64}", Uuid::new_v4().simple());
|
||||
for index in 0..2 {
|
||||
let host = format!("owner-cap-seed-{index}-{}.example", Uuid::new_v4().simple());
|
||||
assert!(matches!(
|
||||
db.create_community_with_owner(&host, &owner, 3)
|
||||
.await
|
||||
.expect("seed owned community"),
|
||||
CreateCommunityWithOwnerOutcome::Created(_)
|
||||
));
|
||||
}
|
||||
|
||||
let first_host = format!("owner-cap-first-{}.example", Uuid::new_v4().simple());
|
||||
let second_host = format!("owner-cap-second-{}.example", Uuid::new_v4().simple());
|
||||
let (first, second) = tokio::join!(
|
||||
db.create_community_with_owner(&first_host, &owner, 3),
|
||||
db.create_community_with_owner(&second_host, &owner, 3),
|
||||
);
|
||||
let outcomes = [first.expect("first create"), second.expect("second create")];
|
||||
|
||||
assert_eq!(
|
||||
outcomes
|
||||
.iter()
|
||||
.filter(|outcome| matches!(outcome, CreateCommunityWithOwnerOutcome::Created(_)))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
outcomes
|
||||
.iter()
|
||||
.filter(|outcome| matches!(
|
||||
outcome,
|
||||
CreateCommunityWithOwnerOutcome::OwnerLimitReached
|
||||
))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
db.list_communities_owned_by(&owner)
|
||||
.await
|
||||
.expect("list capped communities")
|
||||
.len(),
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -160,6 +160,9 @@ pub async fn provision_community(
|
||||
Err(api_error(StatusCode::FORBIDDEN, &msg))
|
||||
}
|
||||
Err(msg) if msg == "community already exists" => Err(api_error(StatusCode::CONFLICT, &msg)),
|
||||
Err(msg) if msg == "owner community limit reached" => {
|
||||
Err(api_error(StatusCode::CONFLICT, "limit_reached"))
|
||||
}
|
||||
Err(msg)
|
||||
if msg.starts_with("failed to create community:")
|
||||
|| msg.starts_with("community provisioned but owner bootstrap failed:") =>
|
||||
|
||||
@@ -51,6 +51,10 @@ pub struct ProvisionCommunityRequest {
|
||||
/// host instead of converging or rotating ownership.
|
||||
#[serde(default)]
|
||||
pub create_only: bool,
|
||||
/// Optional owner quota enforced atomically with create. Product proxies
|
||||
/// supply their resolved entitlement; generic operator callers may omit it.
|
||||
#[serde(default)]
|
||||
pub max_owned_communities: Option<u32>,
|
||||
}
|
||||
|
||||
/// JSON response from `POST /operator/communities`.
|
||||
@@ -253,12 +257,25 @@ pub async fn provision_community(
|
||||
let owner_hex = initial_owner.as_deref().ok_or_else(|| {
|
||||
"initial_owner_pubkey is required when create_only is true".to_string()
|
||||
})?;
|
||||
let record = state
|
||||
let outcome = state
|
||||
.db
|
||||
.create_community_with_owner(&request.host, owner_hex)
|
||||
.create_community_with_owner(
|
||||
&request.host,
|
||||
owner_hex,
|
||||
request.max_owned_communities.unwrap_or(u32::MAX),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("failed to create community: {e}"))?
|
||||
.ok_or_else(|| "community already exists".to_string())?;
|
||||
.map_err(|e| format!("failed to create community: {e}"))?;
|
||||
let (record, status) = match outcome {
|
||||
buzz_db::CreateCommunityWithOwnerOutcome::Created(record) => (record, "created"),
|
||||
buzz_db::CreateCommunityWithOwnerOutcome::Existing(record) => (record, "existed"),
|
||||
buzz_db::CreateCommunityWithOwnerOutcome::HostTaken => {
|
||||
return Err("community already exists".to_string());
|
||||
}
|
||||
buzz_db::CreateCommunityWithOwnerOutcome::OwnerLimitReached => {
|
||||
return Err("owner community limit reached".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
operator = %operator_hex,
|
||||
@@ -270,7 +287,7 @@ pub async fn provision_community(
|
||||
return Ok(ProvisionCommunityResponse {
|
||||
community_id: record.id.to_string(),
|
||||
host: record.host,
|
||||
status: "created",
|
||||
status,
|
||||
owner_pubkey: initial_owner,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user