From d1f5b1e1aa349e7711b676a78a66edf39e72a488 Mon Sep 17 00:00:00 2001 From: Creeperkatze Date: Fri, 10 Jul 2026 16:16:53 +0200 Subject: [PATCH 1/4] Fix random project being stale, add project_type filter --- apps/labrinth/src/routes/v3/projects.rs | 93 +++++++++++++++++++------ 1 file changed, 70 insertions(+), 23 deletions(-) diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 578cd74e3f..385458267c 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -41,6 +41,7 @@ use chrono::Utc; use eyre::eyre; use futures::TryStreamExt; use itertools::Itertools; +use rand::seq::SliceRandom; use serde::{Deserialize, Serialize}; use validator::Validate; @@ -94,11 +95,15 @@ pub async fn clear_project_cache_and_queue_search( pub struct RandomProjects { #[validate(range(min = 1, max = 100))] pub count: u32, + pub project_type: Option, } #[utoipa::path( tag = "projects", - params(("count" = u32, Query)), + params( + ("count" = u32, Query), + ("project_type" = Option, Query), + ), responses((status = OK)) )] #[get("/projects_random")] @@ -110,36 +115,78 @@ pub async fn random_projects_get_route( random_projects_get(count, pool, redis).await } +// Filtered candidates are sparser and unevenly spaced, so the nearest-point pick +// tends to repeat; oversample a neighborhood and shuffle it down to counter that. +const RANDOM_PROJECT_TYPE_OVERSAMPLE_FACTOR: u32 = 20; + pub async fn random_projects_get( - web::Query(count): web::Query, + web::Query(params): web::Query, pool: web::Data, redis: web::Data, ) -> Result { - count.validate().map_err(|err| { + params.validate().map_err(|err| { ApiError::Validation(validation_errors_to_string(err, None)) })?; - let project_ids = sqlx::query!( - // IDs are randomly generated (see the `generate_ids` macro), so fetching a - // number of mods nearest to a random point in the ID space is equivalent to - // random sampling - "WITH random_id_point AS ( - SELECT POINT(RANDOM() * ((SELECT MAX(id) FROM mods) - (SELECT MIN(id) FROM mods) + 1) + (SELECT MIN(id) FROM mods), 0) AS point + let statuses = crate::models::projects::ProjectStatus::iterator() + .filter(|x| x.is_searchable()) + .map(|x| x.to_string()) + .collect::>(); + + let mut project_ids = if let Some(project_type) = ¶ms.project_type { + let fetch_limit = params.count * RANDOM_PROJECT_TYPE_OVERSAMPLE_FACTOR; + + sqlx::query!( + // IDs are randomly generated (see the `generate_ids` macro), so fetching a + // number of mods nearest to a random point in the ID space is equivalent to + // random sampling + "WITH random_id_point AS ( + SELECT POINT(RANDOM() * ((SELECT MAX(id) FROM mods) - (SELECT MIN(id) FROM mods) + 1) + (SELECT MIN(id) FROM mods), 0) AS point + ) + SELECT id FROM mods + WHERE status = ANY($1) + AND EXISTS ( + SELECT 1 FROM versions v + INNER JOIN loaders_versions lv ON v.id = lv.version_id + INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = lv.loader_id + INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id + WHERE v.mod_id = mods.id AND pt.name = $3 + ) + ORDER BY POINT(id, 0) <-> (SELECT point FROM random_id_point) + LIMIT $2", + &statuses, + fetch_limit as i32, + project_type, ) - SELECT id FROM mods - WHERE status = ANY($1) - ORDER BY POINT(id, 0) <-> (SELECT point FROM random_id_point) - LIMIT $2", - &*crate::models::projects::ProjectStatus::iterator() - .filter(|x| x.is_searchable()) - .map(|x| x.to_string()) - .collect::>(), - count.count as i32, - ) - .fetch(&**pool) - .map_ok(|m| db_ids::DBProjectId(m.id)) - .try_collect::>() - .await?; + .fetch(&**pool) + .map_ok(|m| db_ids::DBProjectId(m.id)) + .try_collect::>() + .await? + } else { + sqlx::query!( + // IDs are randomly generated (see the `generate_ids` macro), so fetching a + // number of mods nearest to a random point in the ID space is equivalent to + // random sampling + "WITH random_id_point AS ( + SELECT POINT(RANDOM() * ((SELECT MAX(id) FROM mods) - (SELECT MIN(id) FROM mods) + 1) + (SELECT MIN(id) FROM mods), 0) AS point + ) + SELECT id FROM mods + WHERE status = ANY($1) + ORDER BY POINT(id, 0) <-> (SELECT point FROM random_id_point) + LIMIT $2", + &statuses, + params.count as i32, + ) + .fetch(&**pool) + .map_ok(|m| db_ids::DBProjectId(m.id)) + .try_collect::>() + .await? + }; + + if params.project_type.is_some() { + project_ids.shuffle(&mut rand::thread_rng()); + project_ids.truncate(params.count as usize); + } let projects_data = db_models::DBProject::get_many_ids(&project_ids, &**pool, &redis) From 94e88e0cd5cdedd3ecf525cb8a5ad912ee164a06 Mon Sep 17 00:00:00 2001 From: Creeperkatze Date: Fri, 10 Jul 2026 16:32:46 +0200 Subject: [PATCH 2/4] Avoid full scan --- apps/labrinth/src/routes/v3/projects.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 385458267c..717a6419d9 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -151,6 +151,7 @@ pub async fn random_projects_get( INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = lv.loader_id INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id WHERE v.mod_id = mods.id AND pt.name = $3 + OFFSET 0 ) ORDER BY POINT(id, 0) <-> (SELECT point FROM random_id_point) LIMIT $2", From 63c82603c31d14b582a1ccef5e3d6a37e72b0f36 Mon Sep 17 00:00:00 2001 From: Creeperkatze Date: Fri, 10 Jul 2026 19:28:32 +0200 Subject: [PATCH 3/4] Add comment --- apps/labrinth/src/routes/v3/projects.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 717a6419d9..795bc92e37 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -151,6 +151,8 @@ pub async fn random_projects_get( INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = lv.loader_id INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id WHERE v.mod_id = mods.id AND pt.name = $3 + -- prevents decorrelation, so this stops at the first match instead + -- of scanning all versions before the outer sort/limit applies OFFSET 0 ) ORDER BY POINT(id, 0) <-> (SELECT point FROM random_id_point) From 639742d59e1944493d07bb2d82aa95a44d053c75 Mon Sep 17 00:00:00 2001 From: Creeperkatze Date: Fri, 10 Jul 2026 20:00:42 +0200 Subject: [PATCH 4/4] Run prepare, fix v2 --- ...c1760d2e89af99dd71849eda35d6c6820aa43.json | 23 ------------------ ...7273771aed45eea03e46c23ef74a0a59ecfe3.json | 23 ++++++++++++++++++ ...5b946cd026c718bf4c7d16b23953f6ec5b840.json | 24 +++++++++++++++++++ apps/labrinth/src/routes/v2/projects.rs | 5 +++- 4 files changed, 51 insertions(+), 24 deletions(-) delete mode 100644 apps/labrinth/.sqlx/query-0d01a3991e7551a8b7936bf8f4cc1760d2e89af99dd71849eda35d6c6820aa43.json create mode 100644 apps/labrinth/.sqlx/query-20da3e21ce6115bd80746be3f6e7273771aed45eea03e46c23ef74a0a59ecfe3.json create mode 100644 apps/labrinth/.sqlx/query-c97315540d36355668a1fdd33175b946cd026c718bf4c7d16b23953f6ec5b840.json diff --git a/apps/labrinth/.sqlx/query-0d01a3991e7551a8b7936bf8f4cc1760d2e89af99dd71849eda35d6c6820aa43.json b/apps/labrinth/.sqlx/query-0d01a3991e7551a8b7936bf8f4cc1760d2e89af99dd71849eda35d6c6820aa43.json deleted file mode 100644 index 4a8e5af6aa..0000000000 --- a/apps/labrinth/.sqlx/query-0d01a3991e7551a8b7936bf8f4cc1760d2e89af99dd71849eda35d6c6820aa43.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH random_id_point AS (\n SELECT POINT(RANDOM() * ((SELECT MAX(id) FROM mods) - (SELECT MIN(id) FROM mods) + 1) + (SELECT MIN(id) FROM mods), 0) AS point\n )\n SELECT id FROM mods\n WHERE status = ANY($1)\n ORDER BY POINT(id, 0) <-> (SELECT point FROM random_id_point)\n LIMIT $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "TextArray", - "Int8" - ] - }, - "nullable": [ - false - ] - }, - "hash": "0d01a3991e7551a8b7936bf8f4cc1760d2e89af99dd71849eda35d6c6820aa43" -} diff --git a/apps/labrinth/.sqlx/query-20da3e21ce6115bd80746be3f6e7273771aed45eea03e46c23ef74a0a59ecfe3.json b/apps/labrinth/.sqlx/query-20da3e21ce6115bd80746be3f6e7273771aed45eea03e46c23ef74a0a59ecfe3.json new file mode 100644 index 0000000000..753d7e6d1f --- /dev/null +++ b/apps/labrinth/.sqlx/query-20da3e21ce6115bd80746be3f6e7273771aed45eea03e46c23ef74a0a59ecfe3.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH random_id_point AS (\n SELECT POINT(RANDOM() * ((SELECT MAX(id) FROM mods) - (SELECT MIN(id) FROM mods) + 1) + (SELECT MIN(id) FROM mods), 0) AS point\n )\n SELECT id FROM mods\n WHERE status = ANY($1)\n ORDER BY POINT(id, 0) <-> (SELECT point FROM random_id_point)\n LIMIT $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "TextArray", + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "20da3e21ce6115bd80746be3f6e7273771aed45eea03e46c23ef74a0a59ecfe3" +} diff --git a/apps/labrinth/.sqlx/query-c97315540d36355668a1fdd33175b946cd026c718bf4c7d16b23953f6ec5b840.json b/apps/labrinth/.sqlx/query-c97315540d36355668a1fdd33175b946cd026c718bf4c7d16b23953f6ec5b840.json new file mode 100644 index 0000000000..edcb344f5a --- /dev/null +++ b/apps/labrinth/.sqlx/query-c97315540d36355668a1fdd33175b946cd026c718bf4c7d16b23953f6ec5b840.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH random_id_point AS (\n SELECT POINT(RANDOM() * ((SELECT MAX(id) FROM mods) - (SELECT MIN(id) FROM mods) + 1) + (SELECT MIN(id) FROM mods), 0) AS point\n )\n SELECT id FROM mods\n WHERE status = ANY($1)\n AND EXISTS (\n SELECT 1 FROM versions v\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = lv.loader_id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n WHERE v.mod_id = mods.id AND pt.name = $3\n -- prevents decorrelation, so this stops at the first match instead\n -- of scanning all versions before the outer sort/limit applies\n OFFSET 0\n )\n ORDER BY POINT(id, 0) <-> (SELECT point FROM random_id_point)\n LIMIT $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "TextArray", + "Int8", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c97315540d36355668a1fdd33175b946cd026c718bf4c7d16b23953f6ec5b840" +} diff --git a/apps/labrinth/src/routes/v2/projects.rs b/apps/labrinth/src/routes/v2/projects.rs index fec00c8724..850b05f950 100644 --- a/apps/labrinth/src/routes/v2/projects.rs +++ b/apps/labrinth/src/routes/v2/projects.rs @@ -185,7 +185,10 @@ pub async fn random_projects_get( pool: web::Data, redis: web::Data, ) -> Result { - let count = v3::projects::RandomProjects { count: count.count }; + let count = v3::projects::RandomProjects { + count: count.count, + project_type: None, + }; let response = v3::projects::random_projects_get( web::Query(count),