feat: add project_type filter to random projects endpoint (#6687)

* Fix random project being stale, add project_type filter

* Avoid full scan

* Add comment

* Run prepare, fix v2

* Trigger ci recheck

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Arthur
2026-08-21 21:23:29 +00:00
committed by GitHub
co-authored by Prospector
parent 871a161327
commit fe9ae94a5d
5 changed files with 126 additions and 47 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
+4 -1
View File
@@ -193,7 +193,10 @@ pub async fn random_projects_get(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
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),
+75 -23
View File
@@ -44,6 +44,7 @@ use chrono::Utc;
use eyre::eyre;
use futures::TryStreamExt;
use itertools::Itertools;
use rand::seq::SliceRandom;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use validator::Validate;
@@ -104,11 +105,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<String>,
}
#[utoipa::path(
tag = "projects",
params(("count" = u32, Query)),
params(
("count" = u32, Query),
("project_type" = Option<String>, Query),
),
responses((status = OK))
)]
#[get("/projects_random")]
@@ -120,37 +125,84 @@ 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<RandomProjects>,
web::Query(params): web::Query<RandomProjects>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<HttpResponse, ApiError> {
count
params
.validate()
.map_err(|err| eyre::eyre!(err))
.wrap_request_err("validating request")?;
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::<Vec<String>>();
let mut project_ids = if let Some(project_type) = &params.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
-- 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)
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::<Vec<String>>(),
count.count as i32,
)
.fetch(&**pool)
.map_ok(|m| db_ids::DBProjectId(m.id))
.try_collect::<Vec<_>>()
.await.wrap_internal_err("querying random project IDs")?;
.fetch(&**pool)
.map_ok(|m| db_ids::DBProjectId(m.id))
.try_collect::<Vec<_>>()
.await
.wrap_internal_err("querying random project IDs")?
} 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::<Vec<_>>()
.await
.wrap_internal_err("querying random project IDs")?
};
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)