feat: use postcard for redis serde (#6956)

* redo error handling in xredis

* give proper types to metadata fields

* add round-trip tests

* inline loader enum metadata fields

* postcard roundtrips

* prepare

* bump redis key version

* serde-binhum

* clippy

* fix

* fix frontend checking existence of component fields rather than non-null-ness

* prepare
This commit is contained in:
aecsocket
2026-08-05 10:36:05 +00:00
committed by GitHub
parent cc53d9a6f8
commit 5148e8ec35
70 changed files with 1713 additions and 646 deletions
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, enum_id, value, ordering, metadata, created FROM loader_field_enum_values\n WHERE enum_id = ANY($1)\n ORDER BY enum_id, ordering, created DESC\n ",
"query": "\n SELECT id, enum_id, value, ordering,\n metadata->>'type' AS \"ty?\",\n (metadata->>'major')::boolean AS \"major?\",\n created FROM loader_field_enum_values\n WHERE enum_id = ANY($1)\n ORDER BY enum_id, ordering, created DESC\n ",
"describe": {
"columns": [
{
@@ -25,11 +25,16 @@
},
{
"ordinal": 4,
"name": "metadata",
"type_info": "Jsonb"
"name": "ty?",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "major?",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "created",
"type_info": "Timestamptz"
}
@@ -44,9 +49,10 @@
false,
false,
true,
true,
null,
null,
false
]
},
"hash": "04c04958c71c4fab903c46c9185286e7460a6ff7b03cbc90939ac6c7cb526433"
"hash": "214cc9257db904fd2cc68b8aa8d61d32cab210cfaea2205c95d9ca3bbd0ec1cd"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT DISTINCT id, enum_id, value, ordering, created, metadata\n FROM loader_field_enum_values lfev\n WHERE id = ANY($1)\n ORDER BY enum_id, ordering, created ASC\n ",
"query": "\n SELECT DISTINCT id, enum_id, value, ordering, created,\n metadata->>'type' AS \"ty?\",\n (metadata->>'major')::boolean AS \"major?\"\n FROM loader_field_enum_values lfev\n WHERE id = ANY($1)\n ORDER BY enum_id, ordering, created DESC\n ",
"describe": {
"columns": [
{
@@ -30,8 +30,13 @@
},
{
"ordinal": 5,
"name": "metadata",
"type_info": "Jsonb"
"name": "ty?",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "major?",
"type_info": "Bool"
}
],
"parameters": {
@@ -45,8 +50,9 @@
false,
true,
false,
true
null,
null
]
},
"hash": "d9c4d536ce0bea290f445c3bccb56b4743f2f3a9ce4b170fb439e0e135ca9d51"
"hash": "83b7543e426ae348e589b591f249358c2b9eac5323ae0795581358ed29e5ba65"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT DISTINCT id, enum_id, value, ordering, created, metadata\n FROM loader_field_enum_values lfev\n ORDER BY enum_id, ordering, created DESC\n ",
"query": "\n SELECT DISTINCT id, enum_id, value, ordering, created,\n metadata->>'type' AS \"ty?\",\n (metadata->>'major')::boolean AS \"major?\"\n FROM loader_field_enum_values lfev\n ORDER BY enum_id, ordering, created DESC\n ",
"describe": {
"columns": [
{
@@ -30,8 +30,13 @@
},
{
"ordinal": 5,
"name": "metadata",
"type_info": "Jsonb"
"name": "ty?",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "major?",
"type_info": "Bool"
}
],
"parameters": {
@@ -43,8 +48,9 @@
false,
true,
false,
true
null,
null
]
},
"hash": "fe34673ce6d7bcb616a5ab2e8900d7dfb4e0fa2ee640128d29d6e4beafe60f4c"
"hash": "a8569122a309057326be0d49630119f81d17197673d831d210e351c149e41fc8"
}
@@ -1,50 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT l.id id, l.loader loader, l.icon icon, l.metadata metadata,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games\n FROM loaders l\n LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id\n LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id\n LEFT OUTER JOIN loaders_project_types_games lptg ON lptg.loader_id = lpt.joining_loader_id AND lptg.project_type_id = lpt.joining_project_type_id\n LEFT OUTER JOIN games g ON lptg.game_id = g.id\n GROUP BY l.id;\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "loader",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "icon",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "metadata",
"type_info": "Jsonb"
},
{
"ordinal": 4,
"name": "project_types",
"type_info": "VarcharArray"
},
{
"ordinal": 5,
"name": "games",
"type_info": "VarcharArray"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false,
false,
null,
null
]
},
"hash": "ab9b4b383ce8431214eb26abde734d36eff6902ae55b7ed4d89a10c4d69506a9"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT DISTINCT id, enum_id, value, ordering, created, metadata\n FROM loader_field_enum_values lfev\n WHERE id = ANY($1)\n ORDER BY enum_id, ordering, created DESC\n ",
"query": "\n SELECT DISTINCT id, enum_id, value, ordering, created,\n metadata->>'type' AS \"ty?\",\n (metadata->>'major')::boolean AS \"major?\"\n FROM loader_field_enum_values lfev\n WHERE id = ANY($1)\n ORDER BY enum_id, ordering, created ASC\n ",
"describe": {
"columns": [
{
@@ -30,8 +30,13 @@
},
{
"ordinal": 5,
"name": "metadata",
"type_info": "Jsonb"
"name": "ty?",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "major?",
"type_info": "Bool"
}
],
"parameters": {
@@ -45,8 +50,9 @@
false,
true,
false,
true
null,
null
]
},
"hash": "43d4eafdbcb449a56551d3d6edeba0d6e196fa6539e3f9df107c23a74ba962af"
"hash": "ec9abad348739217eb887e2ac84901ba8dca63912e9d7054115052ad89ab4718"
}
@@ -0,0 +1,50 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT l.id id, l.loader loader, l.icon icon,\n (l.metadata->>'platform')::boolean AS platform,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games\n FROM loaders l\n LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id\n LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id\n LEFT OUTER JOIN loaders_project_types_games lptg ON lptg.loader_id = lpt.joining_loader_id AND lptg.project_type_id = lpt.joining_project_type_id\n LEFT OUTER JOIN games g ON lptg.game_id = g.id\n GROUP BY l.id;\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "loader",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "icon",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "platform",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "project_types",
"type_info": "VarcharArray"
},
{
"ordinal": 5,
"name": "games",
"type_info": "VarcharArray"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false,
null,
null,
null
]
},
"hash": "f21bfe5bcc157d442180bf49c97654bd8650dcdd16cb9f74148bef6e932e2837"
}
+3 -1
View File
@@ -12,8 +12,10 @@
- no trailing punctuation
- wrap code items e.g. type names in backticks
- Prefer `wrap_internal_err`, `wrap_request_err` when attaching context to an existing error (like Anyhow `context` or Eyre `wrap_err`)
- Prefer importing `eyre::Result` and using `Result<T>` instead of `eyre::Result<T>`
- Prefer `eyre::Ok(value)` instead of `Ok::<_, eyre::Report>(value)` when an explicit Eyre result type is needed
- All operations should ideally have some context attached
- Database operations can have a message like `.wrap_internal_err("failed to fetch XYZ")`
- Database operations can have a message like `.wrap_internal_err("fetching XYZ")`
- You can perform real-time queries against the databases in the Docker Compose
- `docker exec labrinth-postgres psql -c "select 1"`
- `docker exec labrinth-redis redis-cli flushall`
+1
View File
@@ -111,6 +111,7 @@ rusty-money = { workspace = true }
scalar_api_reference = { workspace = true, features = ["actix-web"] }
sentry = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde-binhum = { workspace = true }
serde_json = { workspace = true }
serde_with = { workspace = true }
sha1 = { workspace = true }
-6
View File
@@ -59,12 +59,6 @@ pub enum AuthenticationError {
Url,
}
impl From<xredis::Error> for AuthenticationError {
fn from(error: xredis::Error) -> Self {
Self::Database(error.into())
}
}
impl actix_web::ResponseError for AuthenticationError {
fn status_code(&self) -> StatusCode {
match self {
@@ -9,7 +9,7 @@ use crate::{
};
use serde::{Deserialize, Serialize};
const ANALYTICS_EVENTS_NAMESPACE: &str = "analytics_events:v3";
const ANALYTICS_EVENTS_NAMESPACE: &str = "analytics_events:v4";
const ANALYTICS_EVENTS_ALL_KEY: &str = "all";
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -7,7 +7,7 @@ use super::ids::*;
use futures::TryStreamExt;
use serde::{Deserialize, Serialize};
const TAGS_NAMESPACE: &str = "tags:v3";
const TAGS_NAMESPACE: &str = "tags:v4";
pub struct ProjectType {
pub id: ProjectTypeId,
@@ -8,7 +8,7 @@ use futures::TryStreamExt;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
const COLLECTIONS_NAMESPACE: &str = "collections:v3";
const COLLECTIONS_NAMESPACE: &str = "collections:v4";
#[derive(Clone)]
pub struct CollectionBuilder {
+29 -3
View File
@@ -8,14 +8,14 @@ use rand::Rng;
use rand::distributions::Alphanumeric;
use rand_chacha::ChaCha20Rng;
use rand_chacha::rand_core::SeedableRng;
use serde::{Deserialize, Serialize};
use serde_binhum::serde_binhum;
use url::Url;
use webauthn_rs::prelude::{DiscoverableAuthentication, PasskeyRegistration};
use xredis::RedisPool;
const FLOWS_NAMESPACE: &str = "flows:v3";
const FLOWS_NAMESPACE: &str = "flows:v4";
#[derive(Deserialize, Serialize)]
#[serde_binhum]
pub enum DBFlow {
OAuth {
user_id: Option<DBUserId>,
@@ -60,13 +60,39 @@ pub enum DBFlow {
},
RegisterPasskey {
user_id: DBUserId,
#[serde_binhum(binary(with = "json_string"))]
state: PasskeyRegistration,
},
AuthenticatePasskey {
#[serde_binhum(binary(with = "json_string"))]
state: DiscoverableAuthentication,
},
}
mod json_string {
use serde::de::DeserializeOwned;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
where
T: Serialize,
S: Serializer,
{
let value =
serde_json::to_string(value).map_err(serde::ser::Error::custom)?;
value.serialize(serializer)
}
pub fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
where
T: DeserializeOwned,
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
serde_json::from_str(&value).map_err(serde::de::Error::custom)
}
}
impl DBFlow {
pub async fn insert_with_state(
&self,
@@ -6,7 +6,7 @@ use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
const IMAGES_NAMESPACE: &str = "images:v3";
const IMAGES_NAMESPACE: &str = "images:v4";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DBImage {
@@ -117,17 +117,8 @@ impl MinecraftGameVersion {
id: loader_field_enum_value.id,
version: loader_field_enum_value.value,
created: loader_field_enum_value.created,
type_: loader_field_enum_value
.metadata
.get("type")
.and_then(|x| x.as_str())
.map(|x| x.to_string())
.unwrap_or_default(),
major: loader_field_enum_value
.metadata
.get("major")
.and_then(|x| x.as_bool())
.unwrap_or_default(),
type_: loader_field_enum_value.ty.unwrap_or_default(),
major: loader_field_enum_value.major.unwrap_or_default(),
}
}
}
@@ -12,14 +12,14 @@ use itertools::Itertools;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
const GAMES_LIST_NAMESPACE: &str = "games:v3";
const LOADER_ID: &str = "loader_id:v3";
const LOADERS_LIST_NAMESPACE: &str = "loaders:v3";
const LOADER_FIELDS_NAMESPACE: &str = "loader_fields:v3";
const LOADER_FIELDS_NAMESPACE_ALL: &str = "loader_fields_all:v3";
const LOADER_FIELD_ENUMS_ID_NAMESPACE: &str = "loader_field_enums:v3";
const GAMES_LIST_NAMESPACE: &str = "games:v4";
const LOADER_ID: &str = "loader_id:v4";
const LOADERS_LIST_NAMESPACE: &str = "loaders:v4";
const LOADER_FIELDS_NAMESPACE: &str = "loader_fields:v4";
const LOADER_FIELDS_NAMESPACE_ALL: &str = "loader_fields_all:v4";
const LOADER_FIELD_ENUMS_ID_NAMESPACE: &str = "loader_field_enums:v4";
pub const LOADER_FIELD_ENUM_VALUES_NAMESPACE: &str =
"loader_field_enum_values:v3";
"loader_field_enum_values:v4";
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct Game {
@@ -87,6 +87,11 @@ impl Game {
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct LoaderMetadata {
pub platform: Option<bool>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Loader {
pub id: LoaderId,
@@ -94,7 +99,7 @@ pub struct Loader {
pub icon: String,
pub supported_project_types: Vec<String>,
pub supported_games: Vec<String>, // slugs
pub metadata: serde_json::Value,
pub metadata: LoaderMetadata,
}
impl Loader {
@@ -154,7 +159,8 @@ impl Loader {
let result = sqlx::query!(
"
SELECT l.id id, l.loader loader, l.icon icon, l.metadata metadata,
SELECT l.id id, l.loader loader, l.icon icon,
(l.metadata->>'platform')::boolean AS platform,
ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,
ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games
FROM loaders l
@@ -179,7 +185,9 @@ impl Loader {
supported_games: x
.games
.unwrap_or_default(),
metadata: x.metadata
metadata: LoaderMetadata {
platform: x.platform,
},
})
.try_collect::<Vec<_>>()
.await?;
@@ -277,8 +285,9 @@ pub struct LoaderFieldEnumValue {
pub value: String,
pub ordering: Option<i32>,
pub created: DateTime<Utc>,
#[serde(flatten)]
pub metadata: serde_json::Value,
#[serde(rename = "type")]
pub ty: Option<String>,
pub major: Option<bool>,
}
impl std::hash::Hash for LoaderFieldEnumValue {
@@ -357,7 +366,8 @@ pub struct QueryLoaderFieldEnumValue {
pub value: String,
pub ordering: Option<i32>,
pub created: DateTime<Utc>,
pub metadata: Option<serde_json::Value>,
pub ty: Option<String>,
pub major: Option<bool>,
}
impl LoaderField {
@@ -612,42 +622,50 @@ impl LoaderFieldEnumValue {
where
E: crate::database::Executor<'a, Database = sqlx::Postgres>,
{
let val = redis.get_cached_keys_raw(
LOADER_FIELD_ENUM_VALUES_NAMESPACE,
&loader_field_enum_ids.iter().map(|x| x.0).collect::<Vec<_>>(),
|loader_field_enum_ids| async move {
let values = sqlx::query!(
"
SELECT id, enum_id, value, ordering, metadata, created FROM loader_field_enum_values
let val = redis
.get_cached_keys_raw(
LOADER_FIELD_ENUM_VALUES_NAMESPACE,
&loader_field_enum_ids
.iter()
.map(|x| x.0)
.collect::<Vec<_>>(),
|loader_field_enum_ids| async move {
let values = sqlx::query!(
r#"
SELECT id, enum_id, value, ordering,
metadata->>'type' AS "ty?",
(metadata->>'major')::boolean AS "major?",
created FROM loader_field_enum_values
WHERE enum_id = ANY($1)
ORDER BY enum_id, ordering, created DESC
",
&loader_field_enum_ids
)
"#,
&loader_field_enum_ids
)
.fetch(exec)
.try_fold(DashMap::new(), |acc: DashMap<i32, Vec<LoaderFieldEnumValue>>, c| {
let value = LoaderFieldEnumValue {
id: LoaderFieldEnumValueId(c.id),
enum_id: LoaderFieldEnumId(c.enum_id),
value: c.value,
ordering: c.ordering,
created: c.created,
metadata: c.metadata.unwrap_or_default(),
};
.try_fold(
DashMap::new(),
|acc: DashMap<i32, Vec<LoaderFieldEnumValue>>, c| {
let value = LoaderFieldEnumValue {
id: LoaderFieldEnumValueId(c.id),
enum_id: LoaderFieldEnumId(c.enum_id),
value: c.value,
ordering: c.ordering,
created: c.created,
ty: c.ty,
major: c.major,
};
acc.entry(c.enum_id)
.or_default()
.push(value);
acc.entry(c.enum_id).or_default().push(value);
async move {
Ok(acc)
}
})
async move { Ok(acc) }
},
)
.await?;
Ok::<_, DatabaseError>(values)
},
).await?;
Ok::<_, DatabaseError>(values)
},
)
.await?;
Ok(val
.into_iter()
@@ -669,15 +687,16 @@ impl LoaderFieldEnumValue {
.await?
.into_iter()
.filter(|x| {
let mut bool = true;
for (key, value) in &filter {
if let Some(metadata_value) = x.metadata.get(key) {
bool &= metadata_value == value;
} else {
bool = false;
filter.iter().all(|(key, value)| match key.as_str() {
"type" => {
x.ty.as_deref()
.is_some_and(|type_| value.as_str() == Some(type_))
}
}
bool
"major" => x
.major
.is_some_and(|major| value.as_bool() == Some(major)),
_ => false,
})
})
.collect();
@@ -1170,10 +1189,8 @@ impl VersionFieldValue {
value: lfev.value.clone(),
ordering: lfev.ordering,
created: lfev.created,
metadata: lfev
.metadata
.clone()
.unwrap_or_default(),
ty: lfev.ty.clone(),
major: lfev.major,
}
}),
))
@@ -1249,10 +1266,8 @@ impl VersionFieldValue {
value: lfev.value.clone(),
ordering: lfev.ordering,
created: lfev.created,
metadata: lfev
.metadata
.clone()
.unwrap_or_default(),
ty: lfev.ty.clone(),
major: lfev.major,
})
})
.collect::<Result<_, _>>()?,
-2
View File
@@ -77,8 +77,6 @@ pub enum DatabaseError {
SerdeCacheError(#[from] serde_json::Error),
#[error("error while encoding or decoding the cache: {0}")]
PostcardCacheError(#[from] postcard::Error),
#[error(transparent)]
Redis(#[from] xredis::Error),
#[error("Schema error: {0}")]
SchemaError(String),
}
@@ -7,9 +7,9 @@ use xredis::RedisPool;
use super::{DBOrganizationId, DBUserId, DatabaseError};
const MODERATION_NOTES_USERS_NAMESPACE: &str = "moderation_notes_users:v3";
const MODERATION_NOTES_USERS_NAMESPACE: &str = "moderation_notes_users:v4";
const MODERATION_NOTES_ORGANIZATIONS_NAMESPACE: &str =
"moderation_notes_organizations:v3";
"moderation_notes_organizations:v4";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DBModerationNote {
@@ -10,7 +10,7 @@ use futures::TryStreamExt;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
const USER_NOTIFICATIONS_NAMESPACE: &str = "user_notifications:v3";
const USER_NOTIFICATIONS_NAMESPACE: &str = "user_notifications:v4";
pub struct NotificationBuilder {
pub body: NotificationBody,
@@ -1,14 +1,15 @@
use crate::database::models::DatabaseError;
use crate::models::v3::notifications::{NotificationChannel, NotificationType};
use crate::routes::ApiError;
use crate::util::error::Context;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
const TEMPLATES_NAMESPACE: &str = "notifications_templates:v3";
const TEMPLATES_NAMESPACE: &str = "notifications_templates:v4";
const TEMPLATES_HTML_DATA_NAMESPACE: &str =
"notifications_templates_html_data:v3";
"notifications_templates_html_data:v4";
const TEMPLATES_DYNAMIC_HTML_NAMESPACE: &str =
"notifications_templates_dynamic_html:v3";
"notifications_templates_dynamic_html:v4";
const HTML_DATA_CACHE_EXPIRY: i64 = 60 * 15; // 15 minutes
const TEMPLATES_CACHE_EXPIRY: i64 = 60 * 30; // 30 minutes
@@ -123,12 +124,16 @@ where
html: String,
}
let mut redis_conn = redis.connect().await?;
let mut redis_conn = redis.connect().await.wrap_internal_err(
"connecting to redis for dynamic notification html",
)?;
let redis_key = redis_conn
.key()
.metadata(TEMPLATES_DYNAMIC_HTML_NAMESPACE, key);
if let Some(body) =
redis_conn.get_deserialized::<HtmlBody>(&redis_key).await?
if let Some(body) = redis_conn
.get_deserialized::<HtmlBody>(&redis_key)
.await
.wrap_internal_err("fetching dynamic notification html from redis")?
{
return Ok(body.html);
}
@@ -136,14 +141,17 @@ where
drop(redis_conn);
let cached = HtmlBody { html: get().await? };
let mut redis_conn = redis.connect().await?;
let mut redis_conn = redis.connect().await.wrap_internal_err(
"connecting to redis for dynamic notification html",
)?;
let redis_key = redis_conn
.key()
.metadata(TEMPLATES_DYNAMIC_HTML_NAMESPACE, key);
redis_conn
.set_serialized(&redis_key, &cached, Some(HTML_DATA_CACHE_EXPIRY))
.await?;
.await
.wrap_internal_err("writing dynamic notification html to redis")?;
Ok(cached.html)
}
@@ -3,7 +3,7 @@ use crate::models::v3::notifications::NotificationType;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
const NOTIFICATION_TYPES_NAMESPACE: &str = "notification_types:v3";
const NOTIFICATION_TYPES_NAMESPACE: &str = "notification_types:v4";
#[derive(Serialize, Deserialize)]
pub struct NotificationTypeItem {
@@ -9,8 +9,8 @@ use xredis::RedisPool;
use super::{DBTeamMember, ids::*};
use serde::{Deserialize, Serialize};
const ORGANIZATIONS_NAMESPACE: &str = "organizations:v3";
const ORGANIZATIONS_TITLES_NAMESPACE: &str = "organizations_titles:v3";
const ORGANIZATIONS_NAMESPACE: &str = "organizations:v4";
const ORGANIZATIONS_TITLES_NAMESPACE: &str = "organizations_titles:v4";
#[derive(Deserialize, Serialize, Clone, Debug)]
/// An organization of users who together control one or more projects and organizations.
@@ -11,9 +11,9 @@ use std::fmt::{Debug, Display};
use std::hash::Hash;
use xredis::RedisPool;
const PATS_NAMESPACE: &str = "pats:v3";
const PATS_TOKENS_NAMESPACE: &str = "pats_tokens:v3";
const PATS_USERS_NAMESPACE: &str = "pats_users:v3";
const PATS_NAMESPACE: &str = "pats:v4";
const PATS_TOKENS_NAMESPACE: &str = "pats_tokens:v4";
const PATS_USERS_NAMESPACE: &str = "pats_users:v4";
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct DBPersonalAccessToken {
@@ -9,7 +9,7 @@ use std::convert::TryFrom;
use std::convert::TryInto;
use xredis::RedisPool;
const PRODUCTS_NAMESPACE: &str = "products:v3";
const PRODUCTS_NAMESPACE: &str = "products:v4";
pub struct DBProduct {
pub id: DBProductId,
@@ -136,7 +136,7 @@ pub struct QueryProductWithPrices {
pub id: DBProductId,
pub metadata: ProductMetadata,
pub unitary: bool,
#[serde(skip_serializing_if = "Option::is_none", default)]
#[serde(default)]
pub name: Option<String>,
pub prices: Vec<DBProductPrice>,
}
@@ -19,13 +19,14 @@ use dashmap::{DashMap, DashSet};
use futures::TryStreamExt;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use serde_binhum::serde_binhum;
use std::fmt::{Debug, Display};
use std::hash::Hash;
use xredis::RedisPool;
pub const PROJECTS_NAMESPACE: &str = "projects:v3";
pub const PROJECTS_SLUGS_NAMESPACE: &str = "projects_slugs:v3";
const PROJECTS_DEPENDENCIES_NAMESPACE: &str = "projects_dependencies:v3";
pub const PROJECTS_NAMESPACE: &str = "projects:v4";
pub const PROJECTS_SLUGS_NAMESPACE: &str = "projects_slugs:v4";
const PROJECTS_DEPENDENCIES_NAMESPACE: &str = "projects_dependencies:v4";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LinkUrl {
@@ -657,12 +658,14 @@ impl DBProject {
.await?;
let loader_field_enum_values: Vec<QueryLoaderFieldEnumValue> = sqlx::query!(
"
SELECT DISTINCT id, enum_id, value, ordering, created, metadata
r#"
SELECT DISTINCT id, enum_id, value, ordering, created,
metadata->>'type' AS "ty?",
(metadata->>'major')::boolean AS "major?"
FROM loader_field_enum_values lfev
WHERE id = ANY($1)
ORDER BY enum_id, ordering, created DESC
",
"#,
&loader_field_enum_value_ids
.iter()
.map(|x| x.0)
@@ -675,7 +678,8 @@ impl DBProject {
value: m.value,
ordering: m.ordering,
created: m.created,
metadata: m.metadata,
ty: m.ty,
major: m.major,
})
.try_collect()
.await?;
@@ -946,7 +950,7 @@ impl DBProject {
},
)
.await
.wrap_internal_err("failed to fetch cached projects")?;
.wrap_internal_err("fetching cached projects")?;
Ok(val)
}
@@ -1041,8 +1045,10 @@ impl DBProject {
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde_binhum]
#[derive(Clone, Debug)]
pub struct ProjectQueryResult {
#[serde(flatten)]
pub inner: DBProject,
pub categories: Vec<String>,
pub additional_categories: Vec<String>,
@@ -1053,6 +1059,5 @@ pub struct ProjectQueryResult {
pub gallery_items: Vec<DBGalleryItem>,
pub thread_id: DBThreadId,
pub aggregate_version_fields: Vec<VersionField>,
#[serde(flatten)]
pub components: exp::ProjectQuery,
}
@@ -10,9 +10,9 @@ use std::fmt::{Debug, Display};
use std::hash::Hash;
use xredis::RedisPool;
const SESSIONS_NAMESPACE: &str = "sessions:v3";
const SESSIONS_IDS_NAMESPACE: &str = "sessions_ids:v3";
const SESSIONS_USERS_NAMESPACE: &str = "sessions_users:v3";
const SESSIONS_NAMESPACE: &str = "sessions:v4";
const SESSIONS_IDS_NAMESPACE: &str = "sessions_ids:v4";
const SESSIONS_USERS_NAMESPACE: &str = "sessions_users:v4";
pub struct SessionBuilder {
pub session: String,
@@ -10,7 +10,7 @@ use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
const TEAMS_NAMESPACE: &str = "teams:v3";
const TEAMS_NAMESPACE: &str = "teams:v4";
pub struct TeamBuilder {
pub members: Vec<TeamMemberBuilder>,
@@ -16,9 +16,9 @@ use std::fmt::{Debug, Display};
use std::hash::Hash;
use xredis::RedisPool;
const USERS_NAMESPACE: &str = "users:v3";
const USER_USERNAMES_NAMESPACE: &str = "users_usernames:v3";
const USERS_PROJECTS_NAMESPACE: &str = "users_projects:v3";
const USERS_NAMESPACE: &str = "users:v4";
const USER_USERNAMES_NAMESPACE: &str = "users_usernames:v4";
const USERS_PROJECTS_NAMESPACE: &str = "users_projects:v4";
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct DBUser {
@@ -17,12 +17,13 @@ use dashmap::{DashMap, DashSet};
use futures::TryStreamExt;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use serde_binhum::serde_binhum;
use std::cmp::Ordering;
use std::collections::HashMap;
use tracing::error;
pub const VERSIONS_NAMESPACE: &str = "versions:v3";
const VERSION_FILES_NAMESPACE: &str = "versions_files:v3";
pub const VERSIONS_NAMESPACE: &str = "versions:v4";
const VERSION_FILES_NAMESPACE: &str = "versions_files:v4";
pub async fn cleanup_unused_attribution_files_and_groups(
transaction: &mut PgTransaction<'_>,
@@ -704,12 +705,14 @@ impl DBVersion {
.await?;
let loader_field_enum_values: Vec<QueryLoaderFieldEnumValue> = sqlx::query!(
"
SELECT DISTINCT id, enum_id, value, ordering, created, metadata
r#"
SELECT DISTINCT id, enum_id, value, ordering, created,
metadata->>'type' AS "ty?",
(metadata->>'major')::boolean AS "major?"
FROM loader_field_enum_values lfev
WHERE id = ANY($1)
ORDER BY enum_id, ordering, created ASC
",
"#,
&loader_field_enum_value_ids
.iter()
.map(|x| x.0)
@@ -722,7 +725,8 @@ impl DBVersion {
value: m.value,
ordering: m.ordering,
created: m.created,
metadata: m.metadata,
ty: m.ty,
major: m.major,
})
.try_collect()
.await?;
@@ -1080,8 +1084,10 @@ impl DBVersion {
}
}
#[derive(Clone, Deserialize, Serialize)]
#[serde_binhum]
#[derive(Clone)]
pub struct VersionQueryResult {
#[serde(flatten)]
pub inner: DBVersion,
pub files: Vec<FileQueryResult>,
@@ -1090,7 +1096,6 @@ pub struct VersionQueryResult {
pub project_types: Vec<String>,
pub games: Vec<String>,
pub dependencies: Vec<DependencyQueryResult>,
#[serde(flatten)]
pub components: exp::VersionQuery,
}
-56
View File
@@ -1,56 +0,0 @@
use std::sync::Arc;
use crate::env::ENV;
struct RedisConfig {
inner: xredis::RedisConfig,
cache_settings: xredis::CacheSettings,
}
impl RedisConfig {
fn from_env() -> Result<Self, xredis::RedisConfigError> {
let inner = xredis::RedisConfig::new(
ENV.REDIS_TOPOLOGY,
ENV.REDIS_CONNECTION_TYPE,
&ENV.REDIS_URL,
ENV.REDIS_WAIT_TIMEOUT_MS,
(
ENV.REDIS_MAX_CONNECTIONS as usize,
ENV.REDIS_MIN_CONNECTIONS,
),
(
ENV.REDIS_CLUSTER_MAX_CONNECTIONS as usize,
ENV.REDIS_CLUSTER_MIN_CONNECTIONS,
),
(ENV.REDIS_BLOCKING_MAX_CONNECTIONS as usize, 0),
ENV.REDIS_CACHE_LOCKING_STRATEGY,
ENV.REDIS_READ_REPLICA_STRATEGY,
)?;
let cache_settings = xredis::CacheSettings {
default_expiry: ENV.REDIS_DEFAULT_EXPIRY,
actual_expiry: ENV.REDIS_ACTUAL_EXPIRY,
version_default_expiry: ENV.REDIS_VERSION_DEFAULT_EXPIRY,
version_actual_expiry: ENV.REDIS_VERSION_ACTUAL_EXPIRY,
encoding_format: ENV.REDIS_ENCODING_FORMAT,
compression_algorithm: ENV.REDIS_COMPRESSION_ALGORITHM,
compression_level: ENV.REDIS_COMPRESSION_LEVEL,
compression_threshold_bytes: ENV.REDIS_COMPRESSION_THRESHOLD_BYTES,
compression_min_savings_ratio: ENV
.REDIS_COMPRESSION_MIN_SAVINGS_RATIO,
};
Ok(Self {
inner,
cache_settings,
})
}
}
pub async fn from_env(
meta_namespace: impl Into<Arc<str>>,
) -> xredis::RedisPool {
let config = RedisConfig::from_env().expect("invalid Redis configuration");
xredis::RedisPool::new(meta_namespace, config.inner, config.cache_settings)
.await
.expect("failed to initialize Redis connections")
}
+411
View File
@@ -0,0 +1,411 @@
use std::sync::Arc;
use crate::env::ENV;
use eyre::{Result, WrapErr};
struct RedisConfig {
inner: xredis::RedisConfig,
cache_settings: xredis::CacheSettings,
}
impl RedisConfig {
fn from_env() -> Result<Self> {
let inner = xredis::RedisConfig::new(
ENV.REDIS_TOPOLOGY,
ENV.REDIS_CONNECTION_TYPE,
&ENV.REDIS_URL,
ENV.REDIS_WAIT_TIMEOUT_MS,
(
ENV.REDIS_MAX_CONNECTIONS as usize,
ENV.REDIS_MIN_CONNECTIONS,
),
(
ENV.REDIS_CLUSTER_MAX_CONNECTIONS as usize,
ENV.REDIS_CLUSTER_MIN_CONNECTIONS,
),
(ENV.REDIS_BLOCKING_MAX_CONNECTIONS as usize, 0),
ENV.REDIS_CACHE_LOCKING_STRATEGY,
ENV.REDIS_READ_REPLICA_STRATEGY,
)
.wrap_err("loading Redis configuration from environment")?;
let cache_settings = xredis::CacheSettings {
default_expiry: ENV.REDIS_DEFAULT_EXPIRY,
actual_expiry: ENV.REDIS_ACTUAL_EXPIRY,
version_default_expiry: ENV.REDIS_VERSION_DEFAULT_EXPIRY,
version_actual_expiry: ENV.REDIS_VERSION_ACTUAL_EXPIRY,
encoding_format: ENV.REDIS_ENCODING_FORMAT,
compression_algorithm: ENV.REDIS_COMPRESSION_ALGORITHM,
compression_level: ENV.REDIS_COMPRESSION_LEVEL,
compression_threshold_bytes: ENV.REDIS_COMPRESSION_THRESHOLD_BYTES,
compression_min_savings_ratio: ENV
.REDIS_COMPRESSION_MIN_SAVINGS_RATIO,
};
Ok(Self {
inner,
cache_settings,
})
}
}
pub async fn from_env(
meta_namespace: impl Into<Arc<str>>,
) -> xredis::RedisPool {
let config = RedisConfig::from_env().expect("invalid Redis configuration");
xredis::RedisPool::new(meta_namespace, config.inner, config.cache_settings)
.await
.expect("failed to initialize Redis connections")
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use ::serde::{Serialize, de::DeserializeOwned};
use chrono::Utc;
use url::Url;
use uuid::Uuid;
use webauthn_rs::WebauthnBuilder;
use crate::database::models::flow_item::DBFlow;
use crate::database::models::ids::{
DBNotificationId, DBProjectId, DBTeamId, DBThreadId, DBUserId,
DBVersionId, LoaderFieldEnumId, LoaderFieldEnumValueId, LoaderFieldId,
LoaderId,
};
use crate::database::models::loader_fields::{
Loader, LoaderFieldEnumValue, LoaderMetadata, VersionField,
VersionFieldValue,
};
use crate::database::models::notification_item::DBNotification;
use crate::database::models::project_item::{
DBProject, ProjectQueryResult,
};
use crate::database::models::version_item::{
DBVersion, VersionQueryResult,
};
use crate::models::billing::{Price, ProductMetadata};
use crate::models::exp::{self, minecraft};
use crate::models::notifications::NotificationBody;
use crate::models::projects::{
MonetizationStatus, ProjectStatus, SideTypesMigrationReviewStatus,
VersionStatus,
};
fn postcard_round_trip<T>(value: &T) -> T
where
T: Serialize + DeserializeOwned,
{
let serialized =
postcard::to_allocvec(value).expect("serializing with postcard");
postcard::from_bytes(&serialized).expect("deserializing with postcard")
}
fn loader_field_enum_value(ty: &str, major: bool) -> LoaderFieldEnumValue {
LoaderFieldEnumValue {
id: LoaderFieldEnumValueId(1),
enum_id: LoaderFieldEnumId(2),
value: "1.21.8".to_string(),
ordering: None,
created: Utc::now(),
ty: Some(ty.to_string()),
major: Some(major),
}
}
fn db_project() -> DBProject {
let now = Utc::now();
DBProject {
id: DBProjectId(1),
team_id: DBTeamId(2),
organization_id: None,
name: "project".to_string(),
summary: "summary".to_string(),
description: "description".to_string(),
published: now,
updated: now,
approved: Some(now),
queued: None,
status: ProjectStatus::Approved,
requested_status: None,
downloads: 3,
follows: 4,
icon_url: None,
raw_icon_url: None,
license_url: None,
license: "MIT".to_string(),
slug: Some("project".to_string()),
moderation_message: None,
moderation_message_body: None,
webhook_sent: false,
color: None,
monetization_status: MonetizationStatus::Monetized,
side_types_migration_review_status:
SideTypesMigrationReviewStatus::Reviewed,
loaders: vec!["fabric".to_string()],
components: exp::ProjectSerial::default(),
}
}
fn db_version() -> DBVersion {
DBVersion {
id: DBVersionId(1),
project_id: DBProjectId(2),
author_id: DBUserId(3),
name: "version".to_string(),
version_number: "1.0.0".to_string(),
changelog: "changelog".to_string(),
date_published: Utc::now(),
downloads: 4,
version_type: "release".to_string(),
featured: true,
status: VersionStatus::Listed,
requested_status: None,
ordering: None,
components: exp::VersionSerial::default(),
}
}
#[test]
fn loader_metadata_round_trips_with_postcard() {
for platform in [None, Some(false), Some(true)] {
let metadata = LoaderMetadata { platform };
let round_tripped = postcard_round_trip(&metadata);
assert_eq!(round_tripped.platform, platform);
}
}
#[test]
fn loader_round_trips_with_postcard() {
for platform in [None, Some(false), Some(true)] {
let loader = Loader {
id: LoaderId(1),
loader: "paper".to_string(),
icon: "icon".to_string(),
supported_project_types: vec!["plugin".to_string()],
supported_games: vec!["minecraft-java".to_string()],
metadata: LoaderMetadata { platform },
};
let round_tripped = postcard_round_trip(&loader);
assert_eq!(round_tripped.id, loader.id);
assert_eq!(round_tripped.loader, loader.loader);
assert_eq!(round_tripped.icon, loader.icon);
assert_eq!(
round_tripped.supported_project_types,
loader.supported_project_types
);
assert_eq!(round_tripped.supported_games, loader.supported_games);
assert_eq!(round_tripped.metadata.platform, platform);
}
}
#[test]
fn loader_field_enum_values_round_trip_with_postcard() {
let metadata_values = [
(None, None),
(Some("snapshot"), Some(false)),
(Some("alpha"), Some(false)),
(Some("beta"), Some(true)),
(Some("release"), Some(true)),
(Some("beta"), Some(false)),
(Some("release"), Some(false)),
];
for (ty, major) in metadata_values {
let enum_value = LoaderFieldEnumValue {
id: LoaderFieldEnumValueId(1),
enum_id: LoaderFieldEnumId(2),
value: "1.21.8".to_string(),
ordering: None,
created: Utc::now(),
ty: ty.map(str::to_string),
major,
};
assert_eq!(postcard_round_trip(&enum_value), enum_value);
}
}
#[test]
fn loader_field_enum_value_keeps_flattened_json_layout() {
let enum_value = loader_field_enum_value("release", true);
let json = serde_json::to_value(&enum_value)
.expect("serializing loader field enum value as JSON");
assert_eq!(json.get("type"), Some(&serde_json::json!("release")));
assert_eq!(json.get("major"), Some(&serde_json::json!(true)));
assert!(json.get("metadata").is_none());
assert_eq!(
serde_json::from_value::<LoaderFieldEnumValue>(json)
.expect("deserializing loader field enum value from JSON"),
enum_value
);
}
#[test]
fn non_enum_version_fields_round_trip_with_postcard() {
let values = [
VersionFieldValue::Integer(5),
VersionFieldValue::Text("value".to_string()),
VersionFieldValue::Boolean(true),
VersionFieldValue::ArrayInteger(vec![1, 2]),
VersionFieldValue::ArrayText(vec!["one".to_string()]),
VersionFieldValue::ArrayBoolean(vec![true, false]),
];
for value in values {
let field = VersionField {
version_id: DBVersionId(1),
field_id: LoaderFieldId(2),
field_name: "field".to_string(),
value,
};
assert_eq!(postcard_round_trip(&field), field);
}
}
#[test]
fn flattened_cache_values_round_trip_with_postcard() {
let enum_value = loader_field_enum_value("release", true);
postcard_round_trip(&enum_value);
let version = VersionQueryResult {
inner: db_version(),
files: Vec::new(),
version_fields: vec![VersionField {
version_id: DBVersionId(1),
field_id: LoaderFieldId(2),
field_name: "game_versions".to_string(),
value: VersionFieldValue::Enum(
LoaderFieldEnumId(2),
enum_value,
),
}],
loaders: vec!["fabric".to_string()],
project_types: vec!["mod".to_string()],
games: vec!["minecraft-java".to_string()],
dependencies: Vec::new(),
components: exp::VersionQuery::default(),
};
postcard_round_trip(&version);
let project = ProjectQueryResult {
inner: db_project(),
categories: Vec::new(),
additional_categories: Vec::new(),
versions: vec![DBVersionId(1)],
project_types: vec!["mod".to_string()],
games: vec!["minecraft-java".to_string()],
urls: Vec::new(),
gallery_items: Vec::new(),
thread_id: DBThreadId(1),
aggregate_version_fields: Vec::new(),
components: exp::ProjectQuery::default(),
};
postcard_round_trip(&project);
}
#[test]
fn skipped_cache_fields_round_trip_with_postcard() {
postcard_round_trip(&exp::ProjectSerial::default());
postcard_round_trip(&exp::ProjectQuery::default());
postcard_round_trip(&db_project());
}
#[test]
fn internally_tagged_cache_values_round_trip_with_postcard() {
for metadata in [
ProductMetadata::Midas,
ProductMetadata::Pyro {
cpu: 1,
ram: 2,
swap: 3,
storage: 4,
},
ProductMetadata::Medal {
cpu: 1,
ram: 2,
swap: 3,
storage: 4,
region: "us-east".to_string(),
},
] {
postcard_round_trip(&metadata);
}
for price in [
Price::OneTime { price: 500 },
Price::Recurring {
intervals: HashMap::new(),
},
] {
postcard_round_trip(&price);
}
postcard_round_trip(&NotificationBody::TwoFactorEnabled);
postcard_round_trip(&DBNotification {
id: DBNotificationId(1),
user_id: DBUserId(2),
body: NotificationBody::TwoFactorEnabled,
read: false,
created: Utc::now(),
});
postcard_round_trip(&minecraft::ServerContent::Vanilla {
supported_game_versions: vec!["1.21.8".to_string()],
recommended_game_version: Some("1.21.8".to_string()),
});
postcard_round_trip(&minecraft::ServerContentQuery::Vanilla {
supported_game_versions: vec!["1.21.8".to_string()],
recommended_game_version: Some("1.21.8".to_string()),
});
}
#[test]
fn simple_flow_variants_round_trip_with_postcard() {
assert!(matches!(
postcard_round_trip(&DBFlow::MinecraftAuth),
DBFlow::MinecraftAuth
));
let flow = postcard_round_trip(&DBFlow::Login2FA {
user_id: DBUserId(1),
});
assert!(matches!(
flow,
DBFlow::Login2FA {
user_id: DBUserId(1)
}
));
}
#[test]
fn passkey_flow_variants_round_trip_with_postcard() {
let origin = Url::parse("https://example.com").unwrap();
let webauthn = WebauthnBuilder::new("example.com", &origin)
.unwrap()
.build()
.unwrap();
let (_, registration) = webauthn
.start_passkey_registration(
Uuid::from_u128(1),
"user@example.com",
"user",
None,
)
.unwrap();
postcard_round_trip(&DBFlow::RegisterPasskey {
user_id: DBUserId(1),
state: registration,
});
let (_, authentication) =
webauthn.start_discoverable_authentication().unwrap();
postcard_round_trip(&DBFlow::AuthenticatePasskey {
state: authentication,
});
}
}
+1 -1
View File
@@ -165,7 +165,7 @@ vars! {
REDIS_BLOCKING_MAX_CONNECTIONS: u32 = 256u32;
// The encoding format used for Redis cache values.
REDIS_ENCODING_FORMAT: xredis::EncodingFormat = xredis::EncodingFormat::Json;
REDIS_ENCODING_FORMAT: xredis::EncodingFormat = xredis::EncodingFormat::Postcard;
// The level of LZ4 compression used for Redis cache values. A value of 0 disables compression (supports 1-12)
REDIS_COMPRESSION_LEVEL: i32 = 0i32;
// The compression algorithm used for Redis cache values. Currently only LZ4 is supported.
+5 -2
View File
@@ -3,6 +3,7 @@ use std::time::Duration;
use chrono::{DateTime, Utc};
use eyre::Result;
use serde::{Deserialize, Serialize};
use serde_binhum::serde_binhum;
use tracing::warn;
use validator::Validate;
@@ -324,7 +325,8 @@ impl ComponentEdit for JavaServerProjectEdit {
}
/// What game content a [`JavaServerProject`] is using.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[derive(Debug, Clone)]
#[serde_binhum(schema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ServerContent {
/// Server runs modded content with a modpack found on the Modrinth platform.
@@ -346,7 +348,8 @@ pub enum ServerContent {
}
/// What game content a [`JavaServerProject`] is using.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
#[derive(Debug, Clone)]
#[serde_binhum(schema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ServerContentQuery {
/// Server runs modded content with a modpack found on the Modrinth platform.
+1 -2
View File
@@ -54,7 +54,7 @@ macro_rules! define_project_components {
pub struct ProjectSerial {
$(
#[validate(nested)]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub $field_name: Option<$ty>,
)*
}
@@ -114,7 +114,6 @@ macro_rules! define_project_components {
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProjectQuery {
$(
#[serde(skip_serializing_if = "Option::is_none")]
pub $field_name: Option<Query<$ty>>,
)*
}
+3 -2
View File
@@ -4,6 +4,7 @@ use crate::models::ids::{
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_binhum::serde_binhum;
use std::collections::HashMap;
#[derive(Serialize, Deserialize)]
@@ -14,7 +15,7 @@ pub struct Product {
pub unitary: bool,
}
#[derive(Serialize, Deserialize)]
#[serde_binhum]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ProductMetadata {
Midas,
@@ -55,7 +56,7 @@ pub struct ProductPrice {
pub currency_code: String,
}
#[derive(Serialize, Deserialize)]
#[serde_binhum]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum Price {
OneTime {
+3 -1
View File
@@ -12,6 +12,7 @@ use crate::routes::ApiError;
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_binhum::serde_binhum;
use uuid::Uuid;
#[derive(Serialize, Deserialize)]
@@ -151,7 +152,8 @@ impl NotificationType {
}
}
#[derive(Serialize, Deserialize, Clone)]
#[derive(Clone)]
#[serde_binhum]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum NotificationBody {
ProjectUpdate {
+1 -1
View File
@@ -12,7 +12,7 @@ use crate::{
routes::analytics::MINECRAFT_SERVER_PLAYS, util::error::Context,
};
pub const MINECRAFT_SERVER_ANALYTICS: &str = "minecraft_server_analytics:v3";
pub const MINECRAFT_SERVER_ANALYTICS: &str = "minecraft_server_analytics:v4";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MinecraftServerAnalytics {
+34 -15
View File
@@ -4,6 +4,7 @@ use crate::models::analytics::{
};
use crate::routes::ApiError;
use crate::routes::analytics::MINECRAFT_SERVER_PLAYS;
use crate::util::error::Context;
use dashmap::{DashMap, DashSet};
use std::collections::HashMap;
use tracing::trace;
@@ -11,9 +12,9 @@ use xredis::RedisPool;
pub mod cache;
const DOWNLOADS_NAMESPACE: &str = "downloads:v3";
const VIEWS_NAMESPACE: &str = "views:v3";
const MINECRAFT_SERVER_PLAYS_NAMESPACE: &str = "minecraft_server_plays:v3";
const DOWNLOADS_NAMESPACE: &str = "downloads:v4";
const VIEWS_NAMESPACE: &str = "views:v4";
const MINECRAFT_SERVER_PLAYS_NAMESPACE: &str = "minecraft_server_plays:v4";
const MINECRAFT_SERVER_PLAYS_EXPIRY: u64 = 86_400; // 24 hours
const MINECRAFT_SERVER_PLAYS_LIMIT: u32 = 5;
@@ -142,10 +143,15 @@ impl AnalyticsQueue {
)
})
.collect::<Vec<_>>();
let mut redis_connection = redis.connect().await?;
let mut redis_connection =
redis.connect().await.wrap_internal_err(
"connecting to redis for server play counts",
)?;
let results =
redis_connection.get_many_typed::<u32>(&redis_keys).await?;
let results = redis_connection
.get_many_typed::<u32>(&redis_keys)
.await
.wrap_internal_err("fetching server play counts from redis")?;
for (idx, count) in results.into_iter().enumerate() {
let new_count = if let Some(count) = count {
if count >= MINECRAFT_SERVER_PLAYS_LIMIT {
@@ -164,7 +170,8 @@ impl AnalyticsQueue {
new_count,
Some(MINECRAFT_SERVER_PLAYS_EXPIRY as i64),
)
.await?;
.await
.wrap_internal_err("writing server play count to redis")?;
}
let mut plays = client
@@ -198,10 +205,15 @@ impl AnalyticsQueue {
)
})
.collect::<Vec<_>>();
let mut redis_connection = redis.connect().await?;
let mut redis_connection = redis
.connect()
.await
.wrap_internal_err("connecting to redis for view counts")?;
let results =
redis_connection.get_many_typed::<u32>(&redis_keys).await?;
let results = redis_connection
.get_many_typed::<u32>(&redis_keys)
.await
.wrap_internal_err("fetching view counts from redis")?;
for (idx, count) in results.into_iter().enumerate() {
let new_count =
if let Some((views, monetized)) = raw_views.get_mut(idx) {
@@ -226,7 +238,8 @@ impl AnalyticsQueue {
let key = &redis_keys[idx];
redis_connection
.set(key, new_count, Some(6 * 60 * 60))
.await?;
.await
.wrap_internal_err("writing view count to redis")?;
}
let mut views = client.insert::<PageView>("views").await?;
@@ -267,10 +280,15 @@ impl AnalyticsQueue {
)
})
.collect::<Vec<_>>();
let mut redis_connection = redis.connect().await?;
let mut redis_connection = redis
.connect()
.await
.wrap_internal_err("connecting to redis for download counts")?;
let results =
redis_connection.get_many_typed::<u32>(&redis_keys).await?;
let results = redis_connection
.get_many_typed::<u32>(&redis_keys)
.await
.wrap_internal_err("fetching download counts from redis")?;
for (idx, count) in results.into_iter().enumerate() {
let new_count = if let Some(count) = count {
if count > 5 {
@@ -286,7 +304,8 @@ impl AnalyticsQueue {
let key = &redis_keys[idx];
redis_connection
.set(key, new_count, Some(6 * 60 * 60))
.await?;
.await
.wrap_internal_err("writing download count to redis")?;
}
let mut transaction = pool.begin().await?;
+2 -2
View File
@@ -26,9 +26,9 @@ pub struct ServerPingQueue {
pub incremental_search_queue: IncrementalSearchQueue,
}
pub const REDIS_NAMESPACE: &str = "minecraft_java_server_ping:v3";
pub const REDIS_NAMESPACE: &str = "minecraft_java_server_ping:v4";
pub const REDIS_FAILURE_NAMESPACE: &str =
"minecraft_java_server_ping_failures:v3";
"minecraft_java_server_ping_failures:v4";
pub const CLICKHOUSE_TABLE: &str = "minecraft_java_server_pings";
impl ServerPingQueue {
@@ -68,7 +68,7 @@ pub struct CampaignInfo {
cached_at: DateTime<Utc>,
}
const CAMPAIGN_INFO_CACHE_NAMESPACE: &str = "campaign_info:v3";
const CAMPAIGN_INFO_CACHE_NAMESPACE: &str = "campaign_info:v4";
const CAMPAIGN_INFO_CACHE_STALE_SECONDS: i64 = 15 * 60;
const CAMPAIGN_INFO_CACHE_TTL_SECONDS: i64 = 24 * 60 * 60;
+1 -1
View File
@@ -2213,7 +2213,7 @@ async fn validate_2fa_code(
)
.map_err(|_| AuthenticationError::InvalidCredentials)?;
const TOTP_NAMESPACE: &str = "used_totp:v3";
const TOTP_NAMESPACE: &str = "used_totp:v4";
let mut conn = redis.connect().await?;
let logical_key = format!("{}-{}", input, user_id.0);
let key = redis
-6
View File
@@ -267,12 +267,6 @@ pub enum ApiError {
},
}
impl From<xredis::Error> for ApiError {
fn from(error: xredis::Error) -> Self {
Self::Database(error.into())
}
}
impl ApiError {
pub fn delphi(err: impl Into<eyre::Error>) -> Self {
Self::Delphi(err.into())
+2 -11
View File
@@ -210,18 +210,9 @@ pub async fn game_version_list(
.into_iter()
.map(|f| GameVersionQueryData {
version: f.value,
version_type: f
.metadata
.get("type")
.and_then(|m| m.as_str())
.unwrap_or_default()
.to_string(),
version_type: f.ty.unwrap_or_default(),
date: f.created,
major: f
.metadata
.get("major")
.and_then(|m| m.as_bool())
.unwrap_or_default(),
major: f.major.unwrap_or_default(),
})
.collect::<Vec<_>>();
HttpResponse::Ok().json(fields)
+2 -2
View File
@@ -23,8 +23,8 @@ use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use xredis::RedisPool;
const CONTENT_RESOLVE_CACHE_NAMESPACE: &str = "content_resolve:v3";
const CONTENT_RESOLVE_CACHE_HEAT_NAMESPACE: &str = "content_resolve_heat:v3";
const CONTENT_RESOLVE_CACHE_NAMESPACE: &str = "content_resolve:v4";
const CONTENT_RESOLVE_CACHE_HEAT_NAMESPACE: &str = "content_resolve_heat:v4";
const CONTENT_RESOLVE_CACHE_SCHEMA_VERSION: &str = "v3";
const CONTENT_RESOLVE_CACHE_HEAT_WINDOW_SECONDS: i64 = 60 * 60 * 24;
+2 -1
View File
@@ -6,6 +6,7 @@ use crate::database::models::categories::{
};
use crate::database::models::loader_fields::{
Game, Loader, LoaderField, LoaderFieldEnumValue, LoaderFieldType,
LoaderMetadata,
};
use actix_web::{HttpResponse, get, web};
use xredis::RedisPool;
@@ -103,7 +104,7 @@ pub struct LoaderData {
pub supported_project_types: Vec<String>,
pub supported_games: Vec<String>,
pub supported_fields: Vec<String>, // Available loader fields for this loader
pub metadata: Value,
pub metadata: LoaderMetadata,
}
#[utoipa::path(tag = "tags", responses((status = OK)))]
+7 -4
View File
@@ -394,11 +394,13 @@ async fn build_search_documents(
let loader_field_enum_values: Vec<QueryLoaderFieldEnumValue> =
sqlx::query!(
"
SELECT DISTINCT id, enum_id, value, ordering, created, metadata
r#"
SELECT DISTINCT id, enum_id, value, ordering, created,
metadata->>'type' AS "ty?",
(metadata->>'major')::boolean AS "major?"
FROM loader_field_enum_values lfev
ORDER BY enum_id, ordering, created DESC
"
"#
)
.fetch(pool)
.map_ok(|m| QueryLoaderFieldEnumValue {
@@ -407,7 +409,8 @@ async fn build_search_documents(
value: m.value,
ordering: m.ordering,
created: m.created,
metadata: m.metadata,
ty: m.ty,
major: m.major,
})
.try_collect()
.await?;
+1 -1
View File
@@ -14,7 +14,7 @@ use redis::{RedisWrite, ToRedisArgs, ToSingleRedisArg};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
pub const FRIENDS_CHANNEL_NAME: &str = "friends:v3";
pub const FRIENDS_CHANNEL_NAME: &str = "friends:v4";
#[derive(Serialize, Deserialize)]
pub enum RedisFriendsMessage {
+1 -1
View File
@@ -5,7 +5,7 @@ use redis::AsyncCommands;
use xredis::RedisPool;
const EXPIRY_TIME_SECONDS: i64 = 60;
const USER_STATUS_NAMESPACE: &str = "user_status:v3";
const USER_STATUS_NAMESPACE: &str = "user_status:v4";
pub async fn get_user_status(
user: UserId,
+1 -1
View File
@@ -14,7 +14,7 @@ pub const MODRINTH_GENERATED_PDF_TYPE: HeaderName =
HeaderName::from_static("modrinth-generated-pdf-type");
pub const MODRINTH_PAYMENT_ID: HeaderName =
HeaderName::from_static("modrinth-payment-id");
pub const PAYMENT_STATEMENTS_NAMESPACE: &str = "payment_statements:v3";
pub const PAYMENT_STATEMENTS_NAMESPACE: &str = "payment_statements:v4";
const REDIS_TIMEOUT_MARGIN_MS: u64 = 250;
pub(crate) fn payment_statement_key(
+1 -1
View File
@@ -12,7 +12,7 @@ use std::str::FromStr;
use std::sync::Arc;
use xredis::RedisPool;
const RATE_LIMIT_NAMESPACE: &str = "rate_limit:v3";
const RATE_LIMIT_NAMESPACE: &str = "rate_limit:v4";
const RATE_LIMIT_EXPIRY: i64 = 300; // 5 minutes
const MINUTE_IN_NANOS: i64 = 60_000_000_000;
+2 -12
View File
@@ -574,12 +574,7 @@ async fn minecraft_game_version_update() {
// A couple specific checks- in the dummy data, all game versions are marked as major=false except 1.20.5
let name_to_major = game_versions
.iter()
.map(|x| {
(
x.value.clone(),
x.metadata.get("major").unwrap().as_bool().unwrap(),
)
})
.map(|x| (x.value.clone(), x.major.unwrap()))
.collect::<std::collections::HashMap<_, _>>();
for (name, major) in name_to_major {
if name == "1.20.5" {
@@ -612,12 +607,7 @@ async fn minecraft_game_version_update() {
let name_to_major = game_versions
.iter()
.map(|x| {
(
x.value.clone(),
x.metadata.get("major").unwrap().as_bool().unwrap(),
)
})
.map(|x| (x.value.clone(), x.major.unwrap()))
.collect::<std::collections::HashMap<_, _>>();
// Confirm that the new version is there
assert!(name_to_major.contains_key("1.20.6"));
+20 -20
View File
@@ -22,7 +22,7 @@ use serde_json::json;
use tokio::sync::{Barrier, Notify};
use tokio::time::timeout;
use uuid::Uuid;
use xredis::{KeyBuilder, RedisPool, RedisTopology};
use xredis::{KeyBuilder, RedisPool, RedisTopology, RedisValue};
pub mod common;
@@ -250,7 +250,7 @@ async fn cache_lock_coalesces_concurrent_misses_for_one_key() {
tasks.push(tokio::spawn(async move {
barrier.wait().await;
pool.get_cached_keys_raw(
"single_flight:v3",
"single_flight:v4",
&["shared".to_string()],
move |keys| async move {
fetch_count.fetch_add(1, Ordering::SeqCst);
@@ -292,7 +292,7 @@ async fn cache_lock_coalesces_only_overlapping_keys() {
tasks.push(tokio::spawn(async move {
barrier.wait().await;
pool.get_cached_keys_raw(
"overlapping_locks:v3",
"overlapping_locks:v4",
&requested,
move |keys| async move {
tokio::time::sleep(Duration::from_millis(75)).await;
@@ -333,7 +333,7 @@ async fn cache_lock_does_not_block_independent_keys() {
let slow = tokio::spawn(async move {
slow_pool
.get_cached_keys_raw(
"independent_locks:v3",
"independent_locks:v4",
&["slow".to_string()],
move |keys| async move {
slow_started.notify_one();
@@ -350,7 +350,7 @@ async fn cache_lock_does_not_block_independent_keys() {
let fast = timeout(
Duration::from_secs(1),
pool.get_cached_keys_raw(
"independent_locks:v3",
"independent_locks:v4",
&["fast".to_string()],
|keys| async move {
let values = DashMap::new();
@@ -379,7 +379,7 @@ async fn cache_lock_is_released_after_error_and_cancellation() {
let failed = pool
.get_cached_keys_raw(
"error_recovery:v3",
"error_recovery:v4",
&["key".to_string()],
|_| async {
Err::<DashMap<String, String>, _>(DatabaseError::Internal(
@@ -393,7 +393,7 @@ async fn cache_lock_is_released_after_error_and_cancellation() {
let recovered = timeout(
Duration::from_secs(1),
pool.get_cached_keys_raw(
"error_recovery:v3",
"error_recovery:v4",
&["key".to_string()],
|keys| async move {
let values = DashMap::new();
@@ -413,7 +413,7 @@ async fn cache_lock_is_released_after_error_and_cancellation() {
let cancelled = tokio::spawn(async move {
cancelled_pool
.get_cached_keys_raw(
"cancellation_recovery:v3",
"cancellation_recovery:v4",
&["key".to_string()],
move |_| async move {
cancelled_started.notify_one();
@@ -432,7 +432,7 @@ async fn cache_lock_is_released_after_error_and_cancellation() {
let recovered = timeout(
Duration::from_secs(1),
pool.get_cached_keys_raw(
"cancellation_recovery:v3",
"cancellation_recovery:v4",
&["key".to_string()],
|keys| async move {
let values = DashMap::new();
@@ -452,19 +452,19 @@ async fn cache_lock_is_released_after_error_and_cancellation() {
#[actix_rt::test]
async fn expired_cache_value_serves_waiter_while_writer_refreshes() {
let pool = isolated_redis_pool("stale_while_revalidate").await;
let namespace = "stale_while_revalidate:v3";
let namespace = "stale_while_revalidate:v4";
let logical_key = "key".to_string();
let mut connection = pool.connect().await.unwrap();
let redis_key = connection.key().entity(namespace, &logical_key);
connection
.set_serialized(
&redis_key,
json!({
"key": logical_key,
"alias": null,
"iat": 0,
"val": "stale",
}),
RedisValue::<String, String, String>::new(
logical_key.clone(),
None,
0,
"stale".to_string(),
),
None,
)
.await
@@ -539,8 +539,8 @@ async fn case_insensitive_slug_requests_share_one_cache_lock() {
let requested = vec![requested];
barrier.wait().await;
pool.get_cached_keys_raw_with_slug(
"slug_values:v3",
Some("slug_aliases:v3"),
"slug_values:v4",
Some("slug_aliases:v4"),
false,
&requested,
move |_| async move {
@@ -651,11 +651,11 @@ async fn many_get_routes_handle_cross_slot_cache_lifecycle() {
redis.key().entity(VERSIONS_NAMESPACE, alpha_version_id),
redis.key().entity(VERSIONS_NAMESPACE, beta_version_id),
redis.key().entity(
"versions_files:v3",
"versions_files:v4",
format!("sha1_{}", alpha.file_hash),
),
redis.key().entity(
"versions_files:v3",
"versions_files:v4",
format!("sha1_{}", beta.file_hash),
),
];
+1 -6
View File
@@ -47,12 +47,7 @@ async fn get_tags_v3() {
let loader_metadata = loaders
.into_iter()
.map(|x| {
(
x.name,
x.metadata.get("platform").and_then(|x| x.as_bool()),
)
})
.map(|x| (x.name, x.metadata.platform))
.collect::<HashMap<_, _>>();
let loader_names =
loader_metadata.keys().cloned().collect::<HashSet<String>>();