mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
perf(users): read only the partial columns for partial requests (#2203)
This commit is contained in:
+391
-57
@@ -17,6 +17,7 @@ use scylla::statement::prepared::PreparedStatement;
|
||||
#[cfg(feature = "scylla")]
|
||||
use scylla::value::MaybeEmpty;
|
||||
use serde::Deserialize;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -51,6 +52,12 @@ const FULL_USER_COLUMNS: &str = "\
|
||||
premium_grace_ends_at, mention_flags, \
|
||||
last_voice_activity_sharing_change_at, \
|
||||
timezone, timezone_privacy_flags";
|
||||
#[cfg(feature = "scylla")]
|
||||
const PARTIAL_USER_COLUMNS: &str = "\
|
||||
user_id, username, discriminator, global_name, \
|
||||
avatar_hash, bot, system, flags, \
|
||||
banner_hash, banner_color, accent_color, avatar_color, \
|
||||
mention_flags";
|
||||
const USER_BATCH_SIZE: usize = 128;
|
||||
const USER_BATCH_CONCURRENCY: usize = 8;
|
||||
const FLUXER_SYSTEM_USER_ID: i64 = 0;
|
||||
@@ -60,15 +67,20 @@ const USER_FLAG_STAFF: i64 = 1;
|
||||
|
||||
pub struct UsersShard {
|
||||
storage: UsersStorage,
|
||||
cache: Cache<i64, Option<User>>,
|
||||
caches: UserCaches,
|
||||
transport: NatsTransport,
|
||||
}
|
||||
|
||||
struct UserCaches {
|
||||
full: Cache<i64, Option<User>>,
|
||||
partial: Cache<i64, Option<UserPartial>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum UsersStorage {
|
||||
Postgres(PostgresUsersStorage),
|
||||
#[cfg(feature = "scylla")]
|
||||
Scylla(ScyllaUsersStorage),
|
||||
Scylla(Arc<ScyllaUsersStorage>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -77,11 +89,11 @@ struct PostgresUsersStorage {
|
||||
}
|
||||
|
||||
#[cfg(feature = "scylla")]
|
||||
#[derive(Clone)]
|
||||
struct ScyllaUsersStorage {
|
||||
db: Arc<Session>,
|
||||
stmt_full: PreparedStatement,
|
||||
stmt_full_batch: PreparedStatement,
|
||||
stmt_partial: PreparedStatement,
|
||||
stmt_partial_batch: PreparedStatement,
|
||||
}
|
||||
|
||||
#[cfg(feature = "scylla")]
|
||||
@@ -147,6 +159,41 @@ struct FullUserDbRow {
|
||||
timezone_privacy_flags: Option<i32>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "scylla")]
|
||||
#[derive(Debug, DeserializeRow)]
|
||||
struct PartialUserDbRow {
|
||||
user_id: i64,
|
||||
username: String,
|
||||
discriminator: i32,
|
||||
global_name: Option<String>,
|
||||
avatar_hash: Option<String>,
|
||||
bot: Option<bool>,
|
||||
system: Option<bool>,
|
||||
flags: Option<i64>,
|
||||
banner_hash: Option<String>,
|
||||
banner_color: Option<i32>,
|
||||
accent_color: Option<i32>,
|
||||
avatar_color: Option<i32>,
|
||||
mention_flags: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PartialUserKvRow {
|
||||
user_id: i64,
|
||||
username: String,
|
||||
discriminator: i32,
|
||||
global_name: Option<String>,
|
||||
avatar_hash: Option<String>,
|
||||
bot: Option<bool>,
|
||||
system: Option<bool>,
|
||||
flags: Option<i64>,
|
||||
banner_hash: Option<String>,
|
||||
banner_color: Option<i32>,
|
||||
accent_color: Option<i32>,
|
||||
avatar_color: Option<i32>,
|
||||
mention_flags: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FullUserKvRow {
|
||||
user_id: i64,
|
||||
@@ -209,6 +256,63 @@ struct FullUserKvRow {
|
||||
timezone_privacy_flags: Option<i32>,
|
||||
}
|
||||
|
||||
impl UserCaches {
|
||||
fn new(max_entries: u64, ttl: Duration) -> Self {
|
||||
Self {
|
||||
full: Cache::builder()
|
||||
.max_capacity(max_entries)
|
||||
.time_to_live(ttl)
|
||||
.build(),
|
||||
partial: Cache::builder()
|
||||
.max_capacity(max_entries)
|
||||
.time_to_live(ttl)
|
||||
.build(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_or_fetch_full<F>(&self, user_id: i64, fetch: F) -> anyhow::Result<Option<User>>
|
||||
where
|
||||
F: Future<Output = anyhow::Result<Option<User>>>,
|
||||
{
|
||||
let user = self
|
||||
.full
|
||||
.try_get_with(user_id, fetch)
|
||||
.await
|
||||
.map_err(|e: Arc<anyhow::Error>| anyhow::anyhow!("{e}"))?;
|
||||
self.partial
|
||||
.insert(user_id, user.as_ref().map(User::to_partial))
|
||||
.await;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
async fn get_or_fetch_partial<F>(
|
||||
&self,
|
||||
user_id: i64,
|
||||
fetch: F,
|
||||
) -> anyhow::Result<Option<UserPartial>>
|
||||
where
|
||||
F: Future<Output = anyhow::Result<Option<UserPartial>>>,
|
||||
{
|
||||
self.partial
|
||||
.try_get_with(user_id, fetch)
|
||||
.await
|
||||
.map_err(|e: Arc<anyhow::Error>| anyhow::anyhow!("{e}"))
|
||||
}
|
||||
|
||||
async fn get_partial(&self, user_id: i64) -> Option<Option<UserPartial>> {
|
||||
self.partial.get(&user_id).await
|
||||
}
|
||||
|
||||
async fn insert_partial(&self, user_id: i64, partial: Option<UserPartial>) {
|
||||
self.partial.insert(user_id, partial).await;
|
||||
}
|
||||
|
||||
async fn invalidate(&self, user_id: i64) {
|
||||
self.full.invalidate(&user_id).await;
|
||||
self.partial.invalidate(&user_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
impl UsersShard {
|
||||
pub fn new_postgres(
|
||||
kv: postgres::KvClient,
|
||||
@@ -216,14 +320,9 @@ impl UsersShard {
|
||||
max_entries: u64,
|
||||
ttl: Duration,
|
||||
) -> anyhow::Result<Self> {
|
||||
let cache = Cache::builder()
|
||||
.max_capacity(max_entries)
|
||||
.time_to_live(ttl)
|
||||
.build();
|
||||
|
||||
Ok(Self {
|
||||
storage: UsersStorage::Postgres(PostgresUsersStorage { kv }),
|
||||
cache,
|
||||
caches: UserCaches::new(max_entries, ttl),
|
||||
transport,
|
||||
})
|
||||
}
|
||||
@@ -240,23 +339,25 @@ impl UsersShard {
|
||||
"SELECT {FULL_USER_COLUMNS} FROM users WHERE user_id = ? LIMIT 1"
|
||||
))
|
||||
.await?;
|
||||
let stmt_full_batch = db
|
||||
let stmt_partial = db
|
||||
.prepare(format!(
|
||||
"SELECT {FULL_USER_COLUMNS} FROM users WHERE user_id IN ?"
|
||||
"SELECT {PARTIAL_USER_COLUMNS} FROM users WHERE user_id = ? LIMIT 1"
|
||||
))
|
||||
.await?;
|
||||
let stmt_partial_batch = db
|
||||
.prepare(format!(
|
||||
"SELECT {PARTIAL_USER_COLUMNS} FROM users WHERE user_id IN ?"
|
||||
))
|
||||
.await?;
|
||||
let cache = Cache::builder()
|
||||
.max_capacity(max_entries)
|
||||
.time_to_live(ttl)
|
||||
.build();
|
||||
|
||||
Ok(Self {
|
||||
storage: UsersStorage::Scylla(ScyllaUsersStorage {
|
||||
storage: UsersStorage::Scylla(Arc::new(ScyllaUsersStorage {
|
||||
db,
|
||||
stmt_full,
|
||||
stmt_full_batch,
|
||||
}),
|
||||
cache,
|
||||
stmt_partial,
|
||||
stmt_partial_batch,
|
||||
})),
|
||||
caches: UserCaches::new(max_entries, ttl),
|
||||
transport,
|
||||
})
|
||||
}
|
||||
@@ -266,17 +367,25 @@ impl UsersShard {
|
||||
return Ok(Some(fluxer_system_user()));
|
||||
}
|
||||
let storage = self.storage.clone();
|
||||
self.cache
|
||||
.try_get_with(
|
||||
self.caches
|
||||
.get_or_fetch_full(
|
||||
user_id,
|
||||
async move { storage.fetch_full_user(user_id).await },
|
||||
)
|
||||
.await
|
||||
.map_err(|e: Arc<anyhow::Error>| anyhow::anyhow!("{e}"))
|
||||
}
|
||||
|
||||
async fn get_partial_user(&self, user_id: i64) -> anyhow::Result<Option<UserPartial>> {
|
||||
Ok(self.get_full_user(user_id).await?.map(|u| u.to_partial()))
|
||||
if user_id == FLUXER_SYSTEM_USER_ID {
|
||||
return Ok(Some(fluxer_system_user().to_partial()));
|
||||
}
|
||||
let storage = self.storage.clone();
|
||||
self.caches
|
||||
.get_or_fetch_partial(
|
||||
user_id,
|
||||
async move { storage.fetch_partial_user(user_id).await },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_partial_users(&self, user_ids: Vec<i64>) -> anyhow::Result<Vec<UserPartial>> {
|
||||
@@ -290,8 +399,8 @@ impl UsersShard {
|
||||
partials.push(fluxer_system_user().to_partial());
|
||||
continue;
|
||||
}
|
||||
match self.cache.get(&user_id).await {
|
||||
Some(Some(user)) => partials.push(user.to_partial()),
|
||||
match self.caches.get_partial(user_id).await {
|
||||
Some(Some(partial)) => partials.push(partial),
|
||||
Some(None) => {}
|
||||
None => misses.push(user_id),
|
||||
}
|
||||
@@ -304,12 +413,12 @@ impl UsersShard {
|
||||
.map(<[i64]>::to_vec)
|
||||
.collect::<Vec<_>>();
|
||||
let fetched_batches = stream::iter(batches)
|
||||
.map(|batch| async move { self.fetch_user_batch(batch).await })
|
||||
.map(|batch| async move { self.fetch_partial_batch(batch).await })
|
||||
.buffer_unordered(USER_BATCH_CONCURRENCY)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
for fetched in fetched_batches {
|
||||
partials.extend(fetched?.into_iter().map(|user| user.to_partial()));
|
||||
partials.extend(fetched?);
|
||||
}
|
||||
Ok(partials)
|
||||
}
|
||||
@@ -341,13 +450,13 @@ impl UsersShard {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn fetch_user_batch(&self, user_ids: Vec<i64>) -> anyhow::Result<Vec<User>> {
|
||||
let mut users = Vec::new();
|
||||
async fn fetch_partial_batch(&self, user_ids: Vec<i64>) -> anyhow::Result<Vec<UserPartial>> {
|
||||
let mut partials = Vec::new();
|
||||
let user_ids = user_ids
|
||||
.into_iter()
|
||||
.filter(|user_id| {
|
||||
if *user_id == FLUXER_SYSTEM_USER_ID {
|
||||
users.push(fluxer_system_user());
|
||||
partials.push(fluxer_system_user().to_partial());
|
||||
false
|
||||
} else {
|
||||
true
|
||||
@@ -355,34 +464,39 @@ impl UsersShard {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if user_ids.is_empty() {
|
||||
return Ok(users);
|
||||
return Ok(partials);
|
||||
}
|
||||
let fetched_users = match self.storage.fetch_user_batch(user_ids.clone()).await {
|
||||
Ok(users) => users,
|
||||
let fetched_partials = match self.storage.fetch_partial_batch(user_ids.clone()).await {
|
||||
Ok(partials) => partials,
|
||||
Err(_) => {
|
||||
users.extend(self.fetch_user_batch_individually(user_ids).await?);
|
||||
return Ok(users);
|
||||
partials.extend(self.fetch_partial_batch_individually(user_ids).await?);
|
||||
return Ok(partials);
|
||||
}
|
||||
};
|
||||
let found_ids = fetched_users
|
||||
let found_ids = fetched_partials
|
||||
.iter()
|
||||
.map(|user| user.user_id)
|
||||
.map(|partial| partial.user_id)
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
for user in &fetched_users {
|
||||
self.cache.insert(user.user_id, Some(user.clone())).await;
|
||||
for partial in &fetched_partials {
|
||||
self.caches
|
||||
.insert_partial(partial.user_id, Some(partial.clone()))
|
||||
.await;
|
||||
}
|
||||
for user_id in user_ids {
|
||||
if !found_ids.contains(&user_id) {
|
||||
self.cache.insert(user_id, None).await;
|
||||
self.caches.insert_partial(user_id, None).await;
|
||||
}
|
||||
}
|
||||
users.extend(fetched_users);
|
||||
Ok(users)
|
||||
partials.extend(fetched_partials);
|
||||
Ok(partials)
|
||||
}
|
||||
|
||||
async fn fetch_user_batch_individually(&self, user_ids: Vec<i64>) -> anyhow::Result<Vec<User>> {
|
||||
let users = stream::iter(user_ids)
|
||||
.map(|user_id| async move { self.get_full_user(user_id).await })
|
||||
async fn fetch_partial_batch_individually(
|
||||
&self,
|
||||
user_ids: Vec<i64>,
|
||||
) -> anyhow::Result<Vec<UserPartial>> {
|
||||
let partials = stream::iter(user_ids)
|
||||
.map(|user_id| async move { self.get_partial_user(user_id).await })
|
||||
.buffer_unordered(USER_BATCH_CONCURRENCY)
|
||||
.collect::<Vec<_>>()
|
||||
.await
|
||||
@@ -391,7 +505,7 @@ impl UsersShard {
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
Ok(users)
|
||||
Ok(partials)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,11 +518,19 @@ impl UsersStorage {
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_user_batch(&self, user_ids: Vec<i64>) -> anyhow::Result<Vec<User>> {
|
||||
async fn fetch_partial_user(&self, user_id: i64) -> anyhow::Result<Option<UserPartial>> {
|
||||
match self {
|
||||
UsersStorage::Postgres(storage) => storage.fetch_user_batch(user_ids).await,
|
||||
UsersStorage::Postgres(storage) => storage.fetch_partial_user(user_id).await,
|
||||
#[cfg(feature = "scylla")]
|
||||
UsersStorage::Scylla(storage) => storage.fetch_user_batch(user_ids).await,
|
||||
UsersStorage::Scylla(storage) => storage.fetch_partial_user(user_id).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_partial_batch(&self, user_ids: Vec<i64>) -> anyhow::Result<Vec<UserPartial>> {
|
||||
match self {
|
||||
UsersStorage::Postgres(storage) => storage.fetch_partial_batch(user_ids).await,
|
||||
#[cfg(feature = "scylla")]
|
||||
UsersStorage::Scylla(storage) => storage.fetch_partial_batch(user_ids).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -422,14 +544,22 @@ impl PostgresUsersStorage {
|
||||
decode_postgres_user(row).map(Some)
|
||||
}
|
||||
|
||||
async fn fetch_user_batch(&self, user_ids: Vec<i64>) -> anyhow::Result<Vec<User>> {
|
||||
async fn fetch_partial_user(&self, user_id: i64) -> anyhow::Result<Option<UserPartial>> {
|
||||
let key = postgres::kv_key(&[KeyPart::BigInt(user_id)])?;
|
||||
let Some(row) = self.kv.get_row("users", &key).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
decode_postgres_user_partial(row).map(Some)
|
||||
}
|
||||
|
||||
async fn fetch_partial_batch(&self, user_ids: Vec<i64>) -> anyhow::Result<Vec<UserPartial>> {
|
||||
let keys = user_ids
|
||||
.iter()
|
||||
.map(|user_id| postgres::kv_key(&[KeyPart::BigInt(*user_id)]))
|
||||
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||
let rows = self.kv.get_rows("users", &keys).await?;
|
||||
rows.into_iter()
|
||||
.map(|(_, row)| decode_postgres_user(row))
|
||||
.map(|(_, row)| decode_postgres_user_partial(row))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -443,14 +573,25 @@ impl ScyllaUsersStorage {
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
async fn fetch_user_batch(&self, user_ids: Vec<i64>) -> anyhow::Result<Vec<User>> {
|
||||
async fn fetch_partial_user(&self, user_id: i64) -> anyhow::Result<Option<UserPartial>> {
|
||||
let result = self
|
||||
.db
|
||||
.execute_unpaged(&self.stmt_full_batch, (user_ids,))
|
||||
.execute_unpaged(&self.stmt_partial, (user_id,))
|
||||
.await?;
|
||||
let rows = result.into_rows_result()?;
|
||||
let rows: Vec<FullUserDbRow> = rows.rows::<FullUserDbRow>()?.collect::<Result<_, _>>()?;
|
||||
Ok(rows.into_iter().map(User::from).collect::<Vec<_>>())
|
||||
let partial = rows.maybe_first_row::<PartialUserDbRow>()?.map(Into::into);
|
||||
Ok(partial)
|
||||
}
|
||||
|
||||
async fn fetch_partial_batch(&self, user_ids: Vec<i64>) -> anyhow::Result<Vec<UserPartial>> {
|
||||
let result = self
|
||||
.db
|
||||
.execute_unpaged(&self.stmt_partial_batch, (user_ids,))
|
||||
.await?;
|
||||
let rows = result.into_rows_result()?;
|
||||
let rows: Vec<PartialUserDbRow> =
|
||||
rows.rows::<PartialUserDbRow>()?.collect::<Result<_, _>>()?;
|
||||
Ok(rows.into_iter().map(UserPartial::from).collect::<Vec<_>>())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,6 +601,12 @@ fn decode_postgres_user(row: serde_json::Value) -> anyhow::Result<User> {
|
||||
Ok(row.into())
|
||||
}
|
||||
|
||||
fn decode_postgres_user_partial(row: serde_json::Value) -> anyhow::Result<UserPartial> {
|
||||
let row = postgres::decode_row_dates_as_millis(row)?;
|
||||
let row: PartialUserKvRow = serde_json::from_value(row)?;
|
||||
Ok(row.into())
|
||||
}
|
||||
|
||||
fn fluxer_system_user() -> User {
|
||||
User {
|
||||
user_id: FLUXER_SYSTEM_USER_ID,
|
||||
@@ -556,7 +703,7 @@ impl ShardService for UsersShard {
|
||||
self.get_api_partial_users(user_ids).await?,
|
||||
)),
|
||||
UserRequest::Invalidate { user_id } => {
|
||||
self.cache.invalidate(&user_id).await;
|
||||
self.caches.invalidate(user_id).await;
|
||||
let subject = format!("svc.users.invalidate.{user_id}");
|
||||
self.transport.publish(&subject, &[]).await?;
|
||||
Ok(UserResponse::Invalidated)
|
||||
@@ -587,6 +734,47 @@ fn optional_date_string(value: OptionalDate) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "scylla")]
|
||||
impl From<PartialUserDbRow> for UserPartial {
|
||||
fn from(row: PartialUserDbRow) -> Self {
|
||||
Self {
|
||||
user_id: row.user_id,
|
||||
username: row.username,
|
||||
discriminator: row.discriminator,
|
||||
global_name: row.global_name,
|
||||
avatar_hash: row.avatar_hash,
|
||||
bot: row.bot,
|
||||
system: row.system,
|
||||
flags: row.flags,
|
||||
banner_hash: row.banner_hash,
|
||||
banner_color: row.banner_color,
|
||||
accent_color: row.accent_color,
|
||||
avatar_color: row.avatar_color,
|
||||
mention_flags: row.mention_flags,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PartialUserKvRow> for UserPartial {
|
||||
fn from(row: PartialUserKvRow) -> Self {
|
||||
Self {
|
||||
user_id: row.user_id,
|
||||
username: row.username,
|
||||
discriminator: row.discriminator,
|
||||
global_name: row.global_name,
|
||||
avatar_hash: row.avatar_hash,
|
||||
bot: row.bot,
|
||||
system: row.system,
|
||||
flags: row.flags,
|
||||
banner_hash: row.banner_hash,
|
||||
banner_color: row.banner_color,
|
||||
accent_color: row.accent_color,
|
||||
avatar_color: row.avatar_color,
|
||||
mention_flags: row.mention_flags,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "scylla")]
|
||||
impl From<FullUserDbRow> for User {
|
||||
fn from(row: FullUserDbRow) -> Self {
|
||||
@@ -744,6 +932,152 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn test_user(user_id: i64) -> User {
|
||||
let mut user = fluxer_system_user();
|
||||
user.user_id = user_id;
|
||||
user.username = "Ada".to_owned();
|
||||
user.discriminator = 7;
|
||||
user.global_name = Some("Ada Lovelace".to_owned());
|
||||
user.avatar_hash = Some("avatar_hash".to_owned());
|
||||
user.bot = Some(false);
|
||||
user.system = Some(false);
|
||||
user.email = Some("[email protected]".to_owned());
|
||||
user.bio = Some("analytical engine enjoyer".to_owned());
|
||||
user.stripe_customer_id = Some("cus_123".to_owned());
|
||||
user
|
||||
}
|
||||
|
||||
fn caches() -> UserCaches {
|
||||
UserCaches::new(16, Duration::from_secs(60))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn partial_reads_populate_only_the_partial_cache() {
|
||||
let caches = caches();
|
||||
let partial = test_user(42).to_partial();
|
||||
|
||||
let fetched = caches
|
||||
.get_or_fetch_partial(42, async { Ok(Some(partial)) })
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(fetched.username, "Ada");
|
||||
assert_eq!(
|
||||
caches.get_partial(42).await.unwrap().unwrap().username,
|
||||
"Ada"
|
||||
);
|
||||
assert!(caches.full.get(&42).await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn partial_cache_hits_do_not_reach_storage_again() {
|
||||
let caches = caches();
|
||||
let partial = test_user(42).to_partial();
|
||||
caches
|
||||
.get_or_fetch_partial(42, async { Ok(Some(partial)) })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let fetched = caches
|
||||
.get_or_fetch_partial(42, async { Ok(Some(test_user(7).to_partial())) })
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(fetched.user_id, 42);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_reads_populate_both_caches() {
|
||||
let caches = caches();
|
||||
let user = test_user(42);
|
||||
|
||||
let fetched = caches
|
||||
.get_or_fetch_full(42, async { Ok(Some(user)) })
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(fetched.email.as_deref(), Some("[email protected]"));
|
||||
assert_eq!(
|
||||
caches.full.get(&42).await.unwrap().unwrap().bio.as_deref(),
|
||||
Some("analytical engine enjoyer")
|
||||
);
|
||||
let cached_partial = caches.get_partial(42).await.unwrap().unwrap();
|
||||
assert_eq!(cached_partial.username, "Ada");
|
||||
assert_eq!(cached_partial.avatar_hash.as_deref(), Some("avatar_hash"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_users_are_negatively_cached_in_both_caches() {
|
||||
let caches = caches();
|
||||
|
||||
let fetched = caches
|
||||
.get_or_fetch_full(42, async { Ok(None) })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(fetched.is_none());
|
||||
assert!(matches!(caches.full.get(&42).await, Some(None)));
|
||||
assert!(matches!(caches.get_partial(42).await, Some(None)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalidation_clears_both_caches() {
|
||||
let caches = caches();
|
||||
caches
|
||||
.get_or_fetch_full(42, async { Ok(Some(test_user(42))) })
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(caches.full.get(&42).await.is_some());
|
||||
assert!(caches.get_partial(42).await.is_some());
|
||||
|
||||
caches.invalidate(42).await;
|
||||
|
||||
assert!(caches.full.get(&42).await.is_none());
|
||||
assert!(caches.get_partial(42).await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalidation_clears_a_partial_only_entry() {
|
||||
let caches = caches();
|
||||
caches
|
||||
.get_or_fetch_partial(42, async { Ok(Some(test_user(42).to_partial())) })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
caches.invalidate(42).await;
|
||||
|
||||
assert!(caches.get_partial(42).await.is_none());
|
||||
}
|
||||
|
||||
#[cfg(feature = "scylla")]
|
||||
#[test]
|
||||
fn partial_columns_match_the_user_partial_fields_exactly() {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
let columns = PARTIAL_USER_COLUMNS
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.collect::<BTreeSet<_>>();
|
||||
let partial = serde_json::to_value(test_user(42).to_partial()).unwrap();
|
||||
let fields = partial
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect::<BTreeSet<_>>();
|
||||
let full_columns = FULL_USER_COLUMNS
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
assert_eq!(columns, fields);
|
||||
assert!(columns.is_subset(&full_columns));
|
||||
assert!(columns.len() < full_columns.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fluxer_system_user_partial_is_virtual_id_zero() {
|
||||
let partial = fluxer_system_user().to_partial();
|
||||
|
||||
+131
-13
@@ -173,22 +173,10 @@ impl User {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_api_partial(&self) -> ApiUserPartial {
|
||||
self.to_partial().to_api_partial()
|
||||
}
|
||||
}
|
||||
|
||||
impl UserPartial {
|
||||
pub fn to_api_partial(&self) -> ApiUserPartial {
|
||||
if self.user_id == FLUXER_SYSTEM_USER_ID {
|
||||
return fluxer_system_user();
|
||||
}
|
||||
let flags = self.flags.unwrap_or_default();
|
||||
let visible_flags = if (flags & USER_FLAG_STAFF_HIDDEN) != 0 {
|
||||
PUBLIC_USER_FLAGS_WITHOUT_STAFF
|
||||
} else {
|
||||
PUBLIC_USER_FLAGS
|
||||
};
|
||||
ApiUserPartial {
|
||||
id: self.user_id.to_string(),
|
||||
username: self.username.clone(),
|
||||
@@ -198,12 +186,41 @@ impl UserPartial {
|
||||
avatar_color: self.avatar_color,
|
||||
bot: self.bot.filter(|bot| *bot),
|
||||
system: self.system.filter(|system| *system),
|
||||
flags: (flags & visible_flags) as i32,
|
||||
flags: visible_user_flags(self.flags.unwrap_or_default()),
|
||||
mention_flags: self.mention_flags.filter(|flags| *flags != 0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UserPartial {
|
||||
pub fn to_api_partial(&self) -> ApiUserPartial {
|
||||
if self.user_id == FLUXER_SYSTEM_USER_ID {
|
||||
return fluxer_system_user();
|
||||
}
|
||||
ApiUserPartial {
|
||||
id: self.user_id.to_string(),
|
||||
username: self.username.clone(),
|
||||
discriminator: format!("{:04}", self.discriminator),
|
||||
global_name: self.global_name.clone(),
|
||||
avatar: self.avatar_hash.clone(),
|
||||
avatar_color: self.avatar_color,
|
||||
bot: self.bot.filter(|bot| *bot),
|
||||
system: self.system.filter(|system| *system),
|
||||
flags: visible_user_flags(self.flags.unwrap_or_default()),
|
||||
mention_flags: self.mention_flags.filter(|flags| *flags != 0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn visible_user_flags(flags: i64) -> i32 {
|
||||
let visible_flags = if (flags & USER_FLAG_STAFF_HIDDEN) != 0 {
|
||||
PUBLIC_USER_FLAGS_WITHOUT_STAFF
|
||||
} else {
|
||||
PUBLIC_USER_FLAGS
|
||||
};
|
||||
(flags & visible_flags) as i32
|
||||
}
|
||||
|
||||
fn fluxer_system_user() -> ApiUserPartial {
|
||||
ApiUserPartial {
|
||||
id: FLUXER_SYSTEM_USER_ID.to_string(),
|
||||
@@ -261,6 +278,107 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn user_with_flags(user_id: i64, flags: i64) -> User {
|
||||
User {
|
||||
user_id,
|
||||
username: "Ada".to_owned(),
|
||||
discriminator: 7,
|
||||
bot: Some(false),
|
||||
system: Some(false),
|
||||
email: Some("[email protected]".to_owned()),
|
||||
email_verified: Some(true),
|
||||
email_bounced: Some(false),
|
||||
authenticator_types: vec![1],
|
||||
avatar_hash: Some("avatar_hash".to_owned()),
|
||||
avatar_color: Some(0x336699),
|
||||
banner_hash: Some("banner_hash".to_owned()),
|
||||
banner_color: Some(0x112233),
|
||||
bio: Some("analytical engine enjoyer".to_owned()),
|
||||
accent_color: Some(0x445566),
|
||||
date_of_birth: Some("1815-12-10".to_owned()),
|
||||
locale: Some("en-US".to_owned()),
|
||||
flags: Some(flags),
|
||||
premium_flags: Some(1),
|
||||
global_name: Some("Ada Lovelace".to_owned()),
|
||||
pronouns: Some("she/her".to_owned()),
|
||||
traits: vec!["founder".to_owned()],
|
||||
premium_type: Some(2),
|
||||
premium_since: Some(1_781_526_896_789),
|
||||
premium_until: Some(1_781_526_896_789),
|
||||
premium_gift_extension_ends_at: None,
|
||||
premium_lifetime_sequence: None,
|
||||
premium_billing_cycle: Some("monthly".to_owned()),
|
||||
premium_will_cancel: Some(false),
|
||||
premium_onboarding_dismissed_at: None,
|
||||
has_ever_purchased: Some(true),
|
||||
stripe_subscription_id: Some("sub_123".to_owned()),
|
||||
stripe_customer_id: Some("cus_123".to_owned()),
|
||||
gift_inventory_server_seq: Some(3),
|
||||
gift_inventory_client_seq: Some(3),
|
||||
suspicious_activity_flags: Some(0),
|
||||
terms_agreed_at: Some(1_781_526_896_789),
|
||||
privacy_agreed_at: Some(1_781_526_896_789),
|
||||
last_active_at: Some(1_781_526_896_789),
|
||||
last_active_ip: Some("203.0.113.7".to_owned()),
|
||||
temp_banned_until: None,
|
||||
pending_deletion_at: None,
|
||||
pending_bulk_message_deletion_at: None,
|
||||
pending_bulk_message_deletion_channel_count: None,
|
||||
pending_bulk_message_deletion_message_count: None,
|
||||
password_last_changed_at: Some(1_781_526_896_789),
|
||||
acls: vec!["admin".to_owned()],
|
||||
deletion_reason_code: None,
|
||||
deletion_public_reason: None,
|
||||
deletion_audit_log_reason: None,
|
||||
first_refund_at: None,
|
||||
version: 3,
|
||||
has_verified_phone: Some(true),
|
||||
premium_grace_ends_at: None,
|
||||
mention_flags: Some(2),
|
||||
last_voice_activity_sharing_change_at: None,
|
||||
timezone: Some("Europe/London".to_owned()),
|
||||
timezone_privacy_flags: Some(1),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_api_partial_matches_the_two_step_conversion() {
|
||||
for user_id in [123, FLUXER_SYSTEM_USER_ID] {
|
||||
for flags in [
|
||||
0,
|
||||
USER_FLAG_STAFF,
|
||||
USER_FLAG_STAFF | USER_FLAG_STAFF_HIDDEN | USER_FLAG_PARTNER,
|
||||
USER_FLAG_DELETED,
|
||||
] {
|
||||
let user = user_with_flags(user_id, flags);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(user.to_api_partial()).unwrap(),
|
||||
serde_json::to_value(user.to_partial().to_api_partial()).unwrap(),
|
||||
"user_id {user_id} flags {flags}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_api_partial_ignores_fields_outside_the_partial() {
|
||||
let mut user = user_with_flags(123, USER_FLAG_STAFF);
|
||||
user.mention_flags = Some(0);
|
||||
let api_partial = user.to_api_partial();
|
||||
|
||||
assert_eq!(api_partial.id, "123");
|
||||
assert_eq!(api_partial.username, "Ada");
|
||||
assert_eq!(api_partial.discriminator, "0007");
|
||||
assert_eq!(api_partial.global_name, Some("Ada Lovelace".to_owned()));
|
||||
assert_eq!(api_partial.avatar, Some("avatar_hash".to_owned()));
|
||||
assert_eq!(api_partial.avatar_color, Some(0x336699));
|
||||
assert_eq!(api_partial.bot, None);
|
||||
assert_eq!(api_partial.system, None);
|
||||
assert_eq!(api_partial.flags, USER_FLAG_STAFF as i32);
|
||||
assert_eq!(api_partial.mention_flags, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_partial_requests_use_string_snowflakes() {
|
||||
let request: UserRequest = serde_json::from_value(json!({
|
||||
|
||||
Reference in New Issue
Block a user