perf(users): read only the partial columns for partial requests (#2203)

This commit is contained in:
Hampus
2026-08-31 00:06:48 +02:00
committed by GitHub
parent c4897a7026
commit 15656bd5c8
2 changed files with 522 additions and 70 deletions
+391 -57
View File
@@ -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
View File
@@ -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!({