use es_entity::*;
use sqlx::PgPool;
use tracing::instrument;
use std::collections::{HashMap, HashSet};
use crate::{
outbox::OutboxPublisher,
primitives::{AccountId, JournalId},
};
use super::{
entity::*,
error::*,
graph_validation::{AccountMembership, SetMembership},
};
const ADDVISORY_LOCK_ID: i32 = 123456;
const GRAPH_LOCK_CLASS: i32 = 3;
use account_set_cursor::*;
#[derive(EsRepo, Debug, Clone)]
#[es_repo(
entity = "AccountSet",
columns(
name(
ty = "String",
update(accessor = "values().name"),
list_by,
list_for(by(created_at))
),
journal_id(ty = "JournalId", update(persist = false)),
external_id(
ty = "Option<String>",
update(accessor = "values().external_id"),
list_by
),
),
tbl_prefix = "cala",
post_persist_hook = "publish",
persist_event_context = false
)]
pub(super) struct AccountSetRepo {
pool: PgPool,
publisher: OutboxPublisher,
}
impl AccountSetRepo {
pub fn new(pool: &PgPool, publisher: &OutboxPublisher) -> Self {
Self {
pool: pool.clone(),
publisher: publisher.clone(),
}
}
pub(super) async fn lock_graph_shared_in_op(
&self,
db: &mut impl es_entity::AtomicOperation,
) -> Result<(), AccountSetError> {
sqlx::query!(
"SELECT pg_advisory_xact_lock_shared($1, $2)",
GRAPH_LOCK_CLASS,
ADDVISORY_LOCK_ID
)
.execute(db.as_executor())
.await?;
Ok(())
}
pub(super) async fn assert_no_double_membership(
&self,
db: &mut impl es_entity::AtomicOperation,
members: &[AccountMembership],
) -> Result<(), AccountSetError> {
let account_set_ids: Vec<AccountSetId> = members.iter().map(|m| m.account_set_id).collect();
let account_ids: Vec<AccountId> = members.iter().map(|m| m.account_id).collect();
let row = sqlx::query!(
r#"
WITH RECURSIVE all_seeds AS (
SELECT v.account_id, v.account_set_id
FROM UNNEST($1::uuid[], $2::uuid[]) AS v(account_set_id, account_id)
UNION ALL
SELECT m.member_account_id AS account_id, m.account_set_id
FROM cala_account_set_member_accounts m
WHERE m.member_account_id = ANY($2)
),
containments AS (
SELECT account_id, account_set_id FROM all_seeds
UNION ALL
SELECT c.account_id, e.account_set_id
FROM containments c
JOIN cala_account_set_member_account_sets e
ON e.member_account_set_id = c.account_set_id
)
SELECT EXISTS (
SELECT 1 FROM containments
GROUP BY account_id, account_set_id
HAVING COUNT(*) > 1
) AS "conflict!"
"#,
&account_set_ids as &[AccountSetId],
&account_ids as &[AccountId],
)
.fetch_one(db.as_executor())
.await?;
if row.conflict {
return Err(AccountSetError::MemberAlreadyAdded);
}
Ok(())
}
pub(super) async fn lock_for_set_membership_op(
&self,
db: &mut impl es_entity::AtomicOperation,
) -> Result<(), AccountSetError> {
sqlx::query!(
"SELECT pg_advisory_xact_lock($1, $2)",
GRAPH_LOCK_CLASS,
ADDVISORY_LOCK_ID
)
.execute(db.as_executor())
.await?;
Ok(())
}
pub(super) async fn fetch_set_graph_epoch_in_op(
&self,
db: &mut impl es_entity::AtomicOperation,
) -> Result<i64, AccountSetError> {
Ok(
sqlx::query_scalar("SELECT epoch FROM cala_account_set_graph_epoch")
.fetch_one(db.as_executor())
.await?,
)
}
pub(super) async fn fetch_set_membership_edges_in_op(
&self,
db: &mut impl es_entity::AtomicOperation,
) -> Result<Vec<SetMembership>, AccountSetError> {
let rows: Vec<(AccountSetId, AccountSetId)> = sqlx::query_as(
r#"
SELECT account_set_id, member_account_set_id
FROM cala_account_set_member_account_sets
"#,
)
.fetch_all(db.as_executor())
.await?;
Ok(rows.into_iter().map(SetMembership::from).collect())
}
pub(super) async fn fetch_affected_account_memberships_in_op(
&self,
db: &mut impl es_entity::AtomicOperation,
existing_edges: &[SetMembership],
members: &[SetMembership],
) -> Result<Vec<AccountMembership>, AccountSetError> {
let member_account_set_ids: Vec<AccountSetId> = members
.iter()
.map(|edge| edge.member_account_set_id)
.collect();
let mut children: HashMap<AccountSetId, Vec<AccountSetId>> = HashMap::new();
for edge in existing_edges.iter().chain(members) {
children
.entry(edge.account_set_id)
.or_default()
.push(edge.member_account_set_id);
}
let mut affected_set_ids = HashSet::new();
let mut pending = member_account_set_ids;
while let Some(account_set_id) = pending.pop() {
if affected_set_ids.insert(account_set_id) {
pending.extend(children.get(&account_set_id).into_iter().flatten().copied());
}
}
let affected_set_ids: Vec<_> = affected_set_ids.into_iter().collect();
let candidate_account_ids: Vec<AccountId> = sqlx::query_scalar(
r#"
SELECT DISTINCT member_account_id
FROM cala_account_set_member_accounts
WHERE account_set_id = ANY($1)
ORDER BY member_account_id
"#,
)
.bind(&affected_set_ids)
.fetch_all(db.as_executor())
.await?;
if candidate_account_ids.is_empty() {
return Ok(Vec::new());
}
let rows: Vec<(AccountSetId, AccountId)> = sqlx::query_as(
r#"
SELECT account_set_id, member_account_id
FROM cala_account_set_member_accounts
WHERE member_account_id = ANY($1)
ORDER BY member_account_id, account_set_id
"#,
)
.bind(&candidate_account_ids)
.fetch_all(db.as_executor())
.await?;
Ok(rows.into_iter().map(AccountMembership::from).collect())
}
#[instrument(
level = "debug",
name = "account_set.insert_member_sets",
skip_all,
fields(count = members.len()),
err(level = "warn")
)]
pub(super) async fn insert_member_sets(
&self,
db: &mut impl es_entity::AtomicOperation,
members: &[SetMembership],
) -> Result<(), AccountSetError> {
if members.is_empty() {
return Ok(());
}
let account_set_ids: Vec<AccountSetId> =
members.iter().map(|edge| edge.account_set_id).collect();
let member_account_set_ids: Vec<AccountSetId> = members
.iter()
.map(|edge| edge.member_account_set_id)
.collect();
sqlx::query(
r#"
INSERT INTO cala_account_set_member_account_sets
(account_set_id, member_account_set_id)
SELECT account_set_id, member_account_set_id
FROM UNNEST($1::uuid[], $2::uuid[])
AS proposed(account_set_id, member_account_set_id)
"#,
)
.bind(&account_set_ids)
.bind(&member_account_set_ids)
.execute(db.as_executor())
.await?;
sqlx::query!("UPDATE cala_account_set_graph_epoch SET epoch = epoch + 1")
.execute(db.as_executor())
.await?;
self.publisher
.publish_all(
db,
members.iter().map(|edge| {
crate::outbox::OutboxEventPayload::AccountSetMemberCreated {
account_set_id: edge.account_set_id,
member_id: crate::account_set::AccountSetMemberId::AccountSet(
edge.member_account_set_id,
),
}
}),
)
.await?;
Ok(())
}
#[instrument(
level = "debug",
name = "account_set.remove_member_set",
skip_all,
err(level = "warn")
)]
pub async fn remove_member_set(
&self,
db: &mut impl es_entity::AtomicOperation,
account_set_id: AccountSetId,
member_account_set_id: AccountSetId,
) -> Result<(), AccountSetError> {
sqlx::query!(
"SELECT pg_advisory_xact_lock($1, $2)",
GRAPH_LOCK_CLASS,
ADDVISORY_LOCK_ID
)
.execute(db.as_executor())
.await?;
sqlx::query!(
r#"
DELETE FROM cala_account_set_member_account_sets
WHERE account_set_id = $1 AND member_account_set_id = $2
"#,
account_set_id as AccountSetId,
member_account_set_id as AccountSetId,
)
.execute(db.as_executor())
.await?;
sqlx::query!("UPDATE cala_account_set_graph_epoch SET epoch = epoch + 1")
.execute(db.as_executor())
.await?;
self.publisher
.publish_all(
db,
std::iter::once(crate::outbox::OutboxEventPayload::AccountSetMemberRemoved {
account_set_id,
member_id: crate::account_set::AccountSetMemberId::AccountSet(
member_account_set_id,
),
}),
)
.await?;
Ok(())
}
pub async fn find_where_account_is_member(
&self,
account_id: AccountId,
query: es_entity::PaginatedQueryArgs<AccountSetByNameCursor>,
) -> Result<es_entity::PaginatedQueryRet<AccountSet, AccountSetByNameCursor>, AccountSetError>
{
self.find_where_account_is_member_in_op(&self.pool, account_id, query)
.await
}
pub async fn find_where_account_is_member_in_op(
&self,
op: impl es_entity::IntoOneTimeExecutor<'_>,
account_id: AccountId,
query: es_entity::PaginatedQueryArgs<AccountSetByNameCursor>,
) -> Result<es_entity::PaginatedQueryRet<AccountSet, AccountSetByNameCursor>, AccountSetError>
{
let (entities, has_next_page) = es_entity::es_query!(
tbl_prefix = "cala",
r#"SELECT a.id, a.name, a.created_at
FROM cala_account_sets a
JOIN cala_account_set_member_accounts asm
ON asm.account_set_id = a.id
WHERE asm.member_account_id = $1
AND ((a.name, a.id) > ($3, $2) OR ($3 IS NULL AND $2 IS NULL))
ORDER BY a.name, a.id
LIMIT $4"#,
account_id as AccountId,
query.after.as_ref().map(|c| c.id) as Option<AccountSetId>,
query.after.map(|c| c.name),
query.first as i64 + 1
)
.fetch_n(op, query.first)
.await?;
let mut end_cursor = None;
if let Some(last) = entities.last() {
end_cursor = Some(AccountSetByNameCursor {
id: last.values().id,
name: last.values().name.clone(),
});
}
Ok(es_entity::PaginatedQueryRet {
entities,
has_next_page,
end_cursor,
})
}
pub async fn find_where_account_set_is_member(
&self,
account_set_id: AccountSetId,
query: es_entity::PaginatedQueryArgs<AccountSetByNameCursor>,
) -> Result<es_entity::PaginatedQueryRet<AccountSet, AccountSetByNameCursor>, AccountSetError>
{
self.find_where_account_set_is_member_in_op(&self.pool, account_set_id, query)
.await
}
pub async fn find_where_account_set_is_member_in_op(
&self,
op: impl es_entity::IntoOneTimeExecutor<'_>,
account_set_id: AccountSetId,
query: es_entity::PaginatedQueryArgs<AccountSetByNameCursor>,
) -> Result<es_entity::PaginatedQueryRet<AccountSet, AccountSetByNameCursor>, AccountSetError>
{
let (entities, has_next_page) = es_entity::es_query!(
tbl_prefix = "cala",
r#"SELECT a.id, a.name, a.created_at
FROM cala_account_sets a
JOIN cala_account_set_member_account_sets asm
ON asm.account_set_id = a.id
WHERE asm.member_account_set_id = $1
AND ((a.name, a.id) > ($3, $2) OR ($3 IS NULL AND $2 IS NULL))
ORDER BY a.name, a.id
LIMIT $4"#,
account_set_id as AccountSetId,
query.after.as_ref().map(|c| c.id) as Option<AccountSetId>,
query.after.map(|c| c.name),
query.first as i64 + 1
)
.fetch_n(op, query.first)
.await?;
let mut end_cursor = None;
if let Some(last) = entities.last() {
end_cursor = Some(AccountSetByNameCursor {
id: last.values().id,
name: last.values().name.clone(),
});
}
Ok(es_entity::PaginatedQueryRet {
entities,
has_next_page,
end_cursor,
})
}
pub(super) async fn probe_direct_memberships_in_op(
&self,
op: &mut impl es_entity::AtomicOperation,
account_ids: &[AccountId],
) -> Result<DirectMembershipProbe, AccountSetError> {
let rows = sqlx::query!(
r#"
SELECT
g.epoch AS "epoch!",
m.member_account_id AS "account_id?: AccountId",
m.account_set_id AS "set_id?: AccountSetId"
FROM cala_account_set_graph_epoch g
LEFT JOIN cala_account_set_member_accounts m
ON m.member_account_id = ANY($1)
"#,
account_ids as &[AccountId],
)
.fetch_all(op.as_executor())
.await?;
let epoch = rows.first().map(|row| row.epoch).unwrap_or(i64::MIN);
Ok(DirectMembershipProbe {
epoch,
seeds: rows
.into_iter()
.filter_map(|row| {
Some(AccountMembership {
account_set_id: row.set_id?,
account_id: row.account_id?,
})
})
.collect(),
})
}
#[instrument(
level = "debug",
name = "account_set.walk_mappings_and_lock_in_op",
skip_all,
err(level = "warn")
)]
pub(super) async fn walk_mappings_and_lock_in_op(
&self,
op: impl es_entity::IntoOneTimeExecutor<'_>,
journal_id: JournalId,
(account_ids, currencies): &(Vec<AccountId>, Vec<&str>),
) -> Result<HashMap<AccountId, Vec<AccountSetId>>, AccountSetError> {
let rows = op.into_executor().fetch_all(sqlx::query!(
r#"
WITH RECURSIVE seed AS (
SELECT DISTINCT m.member_account_id AS account_id, m.account_set_id
FROM cala_account_set_member_accounts m
WHERE m.member_account_id = ANY($2)
),
ancestors AS (
SELECT account_id, account_set_id FROM seed
UNION
SELECT a.account_id, e.account_set_id
FROM ancestors a
JOIN cala_account_set_member_account_sets e
ON e.member_account_set_id = a.account_set_id
),
resolved AS (
SELECT a.account_id, a.account_set_id
FROM ancestors a
JOIN cala_account_sets s
ON s.id = a.account_set_id AND s.journal_id = $1
),
locks AS (
SELECT pg_advisory_xact_lock(
hashtext(concat($1::text, t.account_set_id::text, t.currency))
)
FROM (
SELECT DISTINCT r.account_set_id, v.currency
FROM resolved r
JOIN UNNEST($2::uuid[], $3::text[]) AS v(account_id, currency)
ON v.account_id = r.account_id
JOIN cala_accounts acc
ON acc.id = r.account_set_id
AND NOT acc.eventually_consistent
) t
ORDER BY t.account_set_id, t.currency
)
SELECT DISTINCT r.account_id AS "account_id!: AccountId", r.account_set_id AS "set_id!: AccountSetId"
FROM resolved r
WHERE (SELECT COUNT(*) FROM locks) IS NOT NULL
"#,
journal_id as JournalId,
account_ids as &[AccountId],
currencies as &[&str],
))
.await?;
let mut mappings = HashMap::new();
for row in rows {
mappings
.entry(row.account_id)
.or_insert_with(Vec::new)
.push(row.set_id);
}
Ok(mappings)
}
pub(super) async fn lock_resolved_ancestors_in_op(
&self,
op: &mut impl es_entity::AtomicOperation,
journal_id: JournalId,
(set_ids, currencies): &(Vec<AccountSetId>, Vec<&str>),
) -> Result<(), AccountSetError> {
if set_ids.is_empty() {
return Ok(());
}
sqlx::query!(
r#"
SELECT pg_advisory_xact_lock(
hashtext(concat($1::text, v.set_id::text, v.currency))
)
FROM UNNEST($2::uuid[], $3::text[]) AS v(set_id, currency)
"#,
journal_id as JournalId,
set_ids as &[AccountSetId],
currencies as &[&str],
)
.execute(op.as_executor())
.await?;
Ok(())
}
pub(super) async fn fetch_set_graph_nodes_in_op(
&self,
op: &mut impl es_entity::AtomicOperation,
set_ids: &[AccountSetId],
) -> Result<Vec<SetGraphNode>, AccountSetError> {
let rows = sqlx::query!(
r#"
SELECT
s.id AS "set_id!: AccountSetId",
s.journal_id AS "journal_id!: JournalId",
acc.eventually_consistent AS "eventually_consistent!",
e.account_set_id AS "parent_id?: AccountSetId"
FROM cala_account_sets s
JOIN cala_accounts acc
ON acc.id = s.id
LEFT JOIN cala_account_set_member_account_sets e
ON e.member_account_set_id = s.id
WHERE s.id = ANY($1)
"#,
set_ids as &[AccountSetId],
)
.fetch_all(op.as_executor())
.await?;
Ok(rows
.into_iter()
.map(|row| SetGraphNode {
id: row.set_id,
journal_id: row.journal_id,
eventually_consistent: row.eventually_consistent,
parent_id: row.parent_id,
})
.collect())
}
pub(super) async fn fetch_set_graph(&self) -> Result<SetGraphData, AccountSetError> {
let rows = sqlx::query!(
r#"
SELECT
g.epoch AS "epoch!",
s.id AS "set_id?: AccountSetId",
s.journal_id AS "journal_id?: JournalId",
acc.eventually_consistent AS "eventually_consistent?",
e.account_set_id AS "parent_id?: AccountSetId"
FROM cala_account_set_graph_epoch g
LEFT JOIN cala_account_sets s ON TRUE
LEFT JOIN cala_accounts acc ON acc.id = s.id
LEFT JOIN cala_account_set_member_account_sets e
ON e.member_account_set_id = s.id
"#
)
.fetch_all(&self.pool)
.await?;
let epoch = rows.first().map(|row| row.epoch).unwrap_or_default();
let nodes = rows
.into_iter()
.filter_map(|row| {
let (Some(id), Some(journal_id), Some(eventually_consistent)) =
(row.set_id, row.journal_id, row.eventually_consistent)
else {
return None;
};
Some(SetGraphNode {
id,
journal_id,
eventually_consistent,
parent_id: row.parent_id,
})
})
.collect();
Ok(SetGraphData { epoch, nodes })
}
async fn publish(
&self,
op: &mut impl es_entity::AtomicOperation,
entity: &AccountSet,
new_events: es_entity::LastPersisted<'_, AccountSetEvent>,
) -> Result<(), sqlx::Error> {
self.publisher
.publish_entity_events(op, entity, new_events)
.await?;
Ok(())
}
}
pub(super) struct DirectMembershipProbe {
pub epoch: i64,
pub seeds: Vec<AccountMembership>,
}
pub(super) struct SetGraphNode {
pub id: AccountSetId,
pub journal_id: JournalId,
pub eventually_consistent: bool,
pub parent_id: Option<AccountSetId>,
}
pub(super) struct SetGraphData {
pub epoch: i64,
pub nodes: Vec<SetGraphNode>,
}