mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
707c3e8b80 | ||
|
|
9d5d34fde8 | ||
|
|
2d5c26896f | ||
|
|
ea3bb334a8 | ||
|
|
024e079a7d | ||
|
|
d902b281f7 | ||
|
|
c4a0008708 | ||
|
|
835f80ee50 | ||
|
|
155f4091a6 | ||
|
|
e1ee9c364b | ||
|
|
0029a22569 | ||
|
|
211ec20970 | ||
|
|
34997bada5 | ||
|
|
63daac917b | ||
|
|
51ceb9d851 | ||
|
|
51066c476a | ||
|
|
47ef7ee42e | ||
|
|
6fba33d443 | ||
|
|
017f6a5afb | ||
|
|
1ab722411a | ||
|
|
45387e5fb6 | ||
|
|
e362de45fb |
Generated
+24
@@ -2631,6 +2631,29 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "elasticsearch"
|
||||
version = "9.1.0-alpha.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12bb303aa6e1d28c0c86b6fbfe484fd0fd3f512629aeed1ac4f6b85f81d9834a"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"dyn-clone",
|
||||
"flate2",
|
||||
"lazy_static",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"rustc_version",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"tokio",
|
||||
"url",
|
||||
"void",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
@@ -4748,6 +4771,7 @@ dependencies = [
|
||||
"dotenv-build",
|
||||
"dotenvy",
|
||||
"either",
|
||||
"elasticsearch",
|
||||
"eyre",
|
||||
"futures",
|
||||
"futures-util",
|
||||
|
||||
@@ -69,6 +69,7 @@ dotenv-build = "0.1.1"
|
||||
dotenvy = "0.15.7"
|
||||
dunce = "1.0.5"
|
||||
either = "1.15.0"
|
||||
elasticsearch = "9.1.0-alpha.1"
|
||||
encoding_rs = "0.8.35"
|
||||
enumset = "1.1.10"
|
||||
eyre = "0.6.12"
|
||||
|
||||
@@ -16,9 +16,14 @@ DATABASE_URL=postgresql://labrinth:labrinth@labrinth-postgres/labrinth
|
||||
DATABASE_MIN_CONNECTIONS=0
|
||||
DATABASE_MAX_CONNECTIONS=16
|
||||
|
||||
SEARCH_BACKEND=meilisearch
|
||||
MEILISEARCH_READ_ADDR=http://localhost:7700
|
||||
MEILISEARCH_WRITE_ADDRS=http://localhost:7700
|
||||
MEILISEARCH_KEY=modrinth
|
||||
ELASTICSEARCH_URL=http://localhost:9200
|
||||
ELASTICSEARCH_INDEX_PREFIX=labrinth
|
||||
ELASTICSEARCH_USERNAME=elastic
|
||||
ELASTICSEARCH_PASSWORD=elastic
|
||||
|
||||
REDIS_URL=redis://labrinth-redis
|
||||
REDIS_MIN_CONNECTIONS=0
|
||||
|
||||
@@ -16,16 +16,29 @@ DATABASE_URL=postgresql://labrinth:labrinth@localhost/labrinth
|
||||
DATABASE_MIN_CONNECTIONS=0
|
||||
DATABASE_MAX_CONNECTIONS=16
|
||||
|
||||
SEARCH_BACKEND=meilisearch
|
||||
|
||||
# Meilisearch configuration
|
||||
MEILISEARCH_READ_ADDR=http://localhost:7700
|
||||
MEILISEARCH_WRITE_ADDRS=http://localhost:7700
|
||||
# 5 minutes in milliseconds
|
||||
SEARCH_OPERATION_TIMEOUT=300000
|
||||
|
||||
ELASTICSEARCH_URL=http://localhost:9200
|
||||
ELASTICSEARCH_INDEX_PREFIX=labrinth
|
||||
|
||||
# # For a sharded Meilisearch setup (sharded-meilisearch docker compose profile)
|
||||
# MEILISEARCH_READ_ADDR=http://localhost:7710
|
||||
# MEILISEARCH_WRITE_ADDRS=http://localhost:7700,http://localhost:7701
|
||||
|
||||
MEILISEARCH_KEY=modrinth
|
||||
MEILISEARCH_META_NAMESPACE=
|
||||
|
||||
# Elasticsearch configuration
|
||||
ELASTICSEARCH_URL=http://localhost:9200
|
||||
ELASTICSEARCH_INDEX_PREFIX=labrinth
|
||||
ELASTICSEARCH_USERNAME=
|
||||
ELASTICSEARCH_PASSWORD=
|
||||
|
||||
REDIS_URL=redis://localhost
|
||||
REDIS_MIN_CONNECTIONS=0
|
||||
|
||||
@@ -25,7 +25,7 @@ async-stripe = { workspace = true, features = [
|
||||
"billing",
|
||||
"checkout",
|
||||
"connect",
|
||||
"webhook-events",
|
||||
"webhook-events"
|
||||
] }
|
||||
async-trait = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
@@ -43,6 +43,7 @@ deadpool-redis.workspace = true
|
||||
derive_more = { workspace = true, features = ["deref", "deref_mut"] }
|
||||
dotenvy = { workspace = true }
|
||||
either = { workspace = true }
|
||||
elasticsearch = { workspace = true, features = ["experimental-apis"] }
|
||||
eyre = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
@@ -85,11 +86,11 @@ reqwest = { workspace = true, features = [
|
||||
"http2",
|
||||
"json",
|
||||
"multipart",
|
||||
"rustls-tls-webpki-roots",
|
||||
"rustls-tls-webpki-roots"
|
||||
] }
|
||||
rust_decimal = { workspace = true, features = [
|
||||
"serde-with-float",
|
||||
"serde-with-str",
|
||||
"serde-with-str"
|
||||
] }
|
||||
rust_iso3166 = { workspace = true }
|
||||
rust-s3 = { workspace = true }
|
||||
|
||||
@@ -20,8 +20,6 @@ use thiserror::Error;
|
||||
pub enum AuthenticationError {
|
||||
#[error(transparent)]
|
||||
Internal(#[from] eyre::Report),
|
||||
#[error("Environment Error")]
|
||||
Env(#[from] dotenvy::Error),
|
||||
#[error("An unknown database error occurred: {0}")]
|
||||
Sqlx(#[from] sqlx::Error),
|
||||
#[error("Database Error: {0}")]
|
||||
@@ -58,7 +56,6 @@ impl actix_web::ResponseError for AuthenticationError {
|
||||
AuthenticationError::Internal(..) => {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
AuthenticationError::Env(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AuthenticationError::Sqlx(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AuthenticationError::Database(..) => {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
@@ -94,7 +91,6 @@ impl AuthenticationError {
|
||||
pub fn error_name(&self) -> &'static str {
|
||||
match self {
|
||||
AuthenticationError::Internal(..) => "internal_error",
|
||||
AuthenticationError::Env(..) => "environment_error",
|
||||
AuthenticationError::Sqlx(..) => "database_error",
|
||||
AuthenticationError::Database(..) => "database_error",
|
||||
AuthenticationError::SerDe(..) => "invalid_input",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::database;
|
||||
use crate::database::PgPool;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::queue::billing::{index_billing, index_subscriptions};
|
||||
@@ -7,9 +8,9 @@ use crate::queue::payouts::{
|
||||
insert_bank_balances_and_webhook, process_affiliate_payouts,
|
||||
process_payout, remove_payouts_for_refunded_charges,
|
||||
};
|
||||
use crate::search::indexing::index_projects;
|
||||
use crate::search::SearchBackend;
|
||||
use crate::util::anrok;
|
||||
use crate::{database, search};
|
||||
use actix_web::web;
|
||||
use clap::ValueEnum;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
@@ -34,18 +35,19 @@ impl BackgroundTask {
|
||||
pool: PgPool,
|
||||
ro_pool: PgPool,
|
||||
redis_pool: RedisPool,
|
||||
search_config: search::SearchConfig,
|
||||
search_backend: web::Data<dyn SearchBackend>,
|
||||
clickhouse: clickhouse::Client,
|
||||
stripe_client: stripe::Client,
|
||||
anrok_client: anrok::Client,
|
||||
email_queue: EmailQueue,
|
||||
mural_client: muralpay::Client,
|
||||
) {
|
||||
) -> eyre::Result<()> {
|
||||
use BackgroundTask::*;
|
||||
// TODO: all of these tasks should return `eyre::Result`s
|
||||
match self {
|
||||
Migrations => run_migrations().await,
|
||||
IndexSearch => {
|
||||
index_search(ro_pool, redis_pool, search_config).await
|
||||
return index_search(ro_pool, redis_pool, search_backend).await;
|
||||
}
|
||||
ReleaseScheduled => release_scheduled(pool).await,
|
||||
UpdateVersions => update_versions(pool, redis_pool).await,
|
||||
@@ -77,6 +79,7 @@ impl BackgroundTask {
|
||||
run_email(email_queue).await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,14 +125,10 @@ pub async fn run_migrations() {
|
||||
pub async fn index_search(
|
||||
ro_pool: PgPool,
|
||||
redis_pool: RedisPool,
|
||||
search_config: search::SearchConfig,
|
||||
) {
|
||||
search_backend: web::Data<dyn SearchBackend>,
|
||||
) -> eyre::Result<()> {
|
||||
info!("Indexing local database");
|
||||
let result = index_projects(ro_pool, redis_pool, &search_config).await;
|
||||
if let Err(e) = result {
|
||||
warn!("Local project indexing failed: {:?}", e);
|
||||
}
|
||||
info!("Done indexing local database");
|
||||
search_backend.index_projects(ro_pool, redis_pool).await
|
||||
}
|
||||
|
||||
pub async fn release_scheduled(pool: PgPool) {
|
||||
|
||||
@@ -82,6 +82,7 @@ where
|
||||
}
|
||||
|
||||
pub fn init() -> eyre::Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
EnvVars::from_env()?;
|
||||
LazyLock::force(&ENV);
|
||||
Ok(())
|
||||
@@ -128,9 +129,6 @@ vars! {
|
||||
LABRINTH_EXTERNAL_NOTIFICATION_KEY: String;
|
||||
RATE_LIMIT_IGNORE_KEY: String;
|
||||
DATABASE_URL: String;
|
||||
MEILISEARCH_READ_ADDR: String;
|
||||
MEILISEARCH_WRITE_ADDRS: StringCsv;
|
||||
MEILISEARCH_KEY: String;
|
||||
REDIS_URL: String;
|
||||
BIND_ADDR: String;
|
||||
SELF_ADDR: String;
|
||||
@@ -142,6 +140,17 @@ vars! {
|
||||
ALLOWED_CALLBACK_URLS: Json<Vec<String>>;
|
||||
ANALYTICS_ALLOWED_ORIGINS: Json<Vec<String>>;
|
||||
|
||||
// search
|
||||
SEARCH_BACKEND: crate::search::SearchBackendKind;
|
||||
MEILISEARCH_READ_ADDR: String;
|
||||
MEILISEARCH_WRITE_ADDRS: StringCsv;
|
||||
MEILISEARCH_KEY: String;
|
||||
ELASTICSEARCH_URL: String;
|
||||
ELASTICSEARCH_INDEX_PREFIX: String;
|
||||
ELASTICSEARCH_USERNAME: String = "";
|
||||
ELASTICSEARCH_PASSWORD: String = "";
|
||||
ELASTICSEARCH_INDEX_CHUNK_SIZE: i64 = 5000i64;
|
||||
|
||||
// storage
|
||||
STORAGE_BACKEND: crate::file_hosting::FileHostKind;
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ pub struct LabrinthConfig {
|
||||
pub file_host: Arc<dyn file_hosting::FileHost + Send + Sync>,
|
||||
pub scheduler: Arc<scheduler::Scheduler>,
|
||||
pub ip_salt: Pepper,
|
||||
pub search_config: search::SearchConfig,
|
||||
pub search_backend: web::Data<dyn search::SearchBackend>,
|
||||
pub session_queue: web::Data<AuthQueue>,
|
||||
pub payouts_queue: web::Data<PayoutsQueue>,
|
||||
pub analytics_queue: Arc<AnalyticsQueue>,
|
||||
@@ -75,7 +75,7 @@ pub fn app_setup(
|
||||
pool: PgPool,
|
||||
ro_pool: ReadOnlyPgPool,
|
||||
redis_pool: RedisPool,
|
||||
search_config: search::SearchConfig,
|
||||
search_backend: actix_web::web::Data<dyn search::SearchBackend>,
|
||||
clickhouse: &mut Client,
|
||||
file_host: Arc<dyn file_hosting::FileHost + Send + Sync>,
|
||||
stripe_client: stripe::Client,
|
||||
@@ -113,19 +113,22 @@ pub fn app_setup(
|
||||
let local_index_interval =
|
||||
Duration::from_secs(ENV.LOCAL_INDEX_INTERVAL);
|
||||
let pool_ref = pool.clone();
|
||||
let search_config_ref = search_config.clone();
|
||||
let redis_pool_ref = redis_pool.clone();
|
||||
let search_backend_ref = search_backend.clone();
|
||||
scheduler.run(local_index_interval, move || {
|
||||
let pool_ref = pool_ref.clone();
|
||||
let redis_pool_ref = redis_pool_ref.clone();
|
||||
let search_config_ref = search_config_ref.clone();
|
||||
let search_backend = search_backend_ref.clone();
|
||||
async move {
|
||||
background_task::index_search(
|
||||
if let Err(err) = background_task::index_search(
|
||||
pool_ref,
|
||||
redis_pool_ref,
|
||||
search_config_ref,
|
||||
search_backend,
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
warn!("Failed to index search: {err:?}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -269,7 +272,7 @@ pub fn app_setup(
|
||||
file_host,
|
||||
scheduler: Arc::new(scheduler),
|
||||
ip_salt,
|
||||
search_config,
|
||||
search_backend,
|
||||
session_queue,
|
||||
payouts_queue: web::Data::new(PayoutsQueue::new()),
|
||||
analytics_queue,
|
||||
@@ -307,7 +310,7 @@ pub fn app_config(
|
||||
.app_data(web::Data::new(labrinth_config.pool.clone()))
|
||||
.app_data(web::Data::new(labrinth_config.ro_pool.clone()))
|
||||
.app_data(web::Data::new(labrinth_config.file_host.clone()))
|
||||
.app_data(web::Data::new(labrinth_config.search_config.clone()))
|
||||
.app_data(labrinth_config.search_backend.clone())
|
||||
.app_data(web::Data::new(labrinth_config.gotenberg_client.clone()))
|
||||
.app_data(labrinth_config.session_queue.clone())
|
||||
.app_data(labrinth_config.payouts_queue.clone())
|
||||
|
||||
+18
-16
@@ -17,6 +17,7 @@ use labrinth::utoipa_app_config;
|
||||
use labrinth::{app_config, env};
|
||||
use labrinth::{clickhouse, database, file_hosting};
|
||||
use std::ffi::CStr;
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
use tracing::{Instrument, info, info_span};
|
||||
use tracing_actix_web::TracingLogger;
|
||||
@@ -56,7 +57,6 @@ struct Args {
|
||||
|
||||
fn main() -> std::io::Result<()> {
|
||||
color_eyre::install().expect("failed to install `color-eyre`");
|
||||
dotenvy::dotenv().ok();
|
||||
modrinth_util::log::init().expect("failed to initialize logging");
|
||||
env::init().expect("failed to initialize environment variables");
|
||||
|
||||
@@ -152,7 +152,8 @@ async fn app() -> std::io::Result<()> {
|
||||
info!("Initializing clickhouse connection");
|
||||
let mut clickhouse = clickhouse::init_client().await.unwrap();
|
||||
|
||||
let search_config = search::SearchConfig::new(None);
|
||||
let search_backend =
|
||||
actix_web::web::Data::from(Arc::from(search::backend(None)));
|
||||
|
||||
let stripe_client = stripe::Client::new(ENV.STRIPE_API_KEY.clone());
|
||||
|
||||
@@ -167,19 +168,20 @@ async fn app() -> std::io::Result<()> {
|
||||
|
||||
if let Some(task) = args.run_background_task {
|
||||
info!("Running task {task:?} and exiting");
|
||||
task.run(
|
||||
pool,
|
||||
ro_pool.into_inner(),
|
||||
redis_pool,
|
||||
search_config,
|
||||
clickhouse,
|
||||
stripe_client,
|
||||
anrok_client.clone(),
|
||||
email_queue,
|
||||
muralpay,
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
return task
|
||||
.run(
|
||||
pool,
|
||||
ro_pool.into_inner(),
|
||||
redis_pool,
|
||||
search_backend,
|
||||
clickhouse,
|
||||
stripe_client,
|
||||
anrok_client.clone(),
|
||||
email_queue,
|
||||
muralpay,
|
||||
)
|
||||
.await
|
||||
.map_err(io::Error::other);
|
||||
}
|
||||
|
||||
let prometheus = PrometheusMetricsBuilder::new("labrinth")
|
||||
@@ -206,7 +208,7 @@ async fn app() -> std::io::Result<()> {
|
||||
pool.clone(),
|
||||
ro_pool.clone(),
|
||||
redis_pool.clone(),
|
||||
search_config.clone(),
|
||||
search_backend.clone(),
|
||||
&mut clickhouse,
|
||||
file_host.clone(),
|
||||
stripe_client,
|
||||
|
||||
@@ -102,8 +102,6 @@ impl Mailer {
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum MailError {
|
||||
#[error("Environment Error")]
|
||||
Env(#[from] dotenvy::Error),
|
||||
#[error("Mail Error: {0}")]
|
||||
Mail(#[from] lettre::error::Error),
|
||||
#[error("Address Parse Error: {0}")]
|
||||
@@ -136,7 +134,7 @@ impl EmailQueue {
|
||||
pg,
|
||||
redis,
|
||||
mailer: Arc::new(TokioMutex::new(Mailer::Uninitialized)),
|
||||
identity: templates::MailingIdentity::from_env()?,
|
||||
identity: templates::MailingIdentity::from_env(),
|
||||
client: Client::builder()
|
||||
.user_agent("Modrinth")
|
||||
.build()
|
||||
|
||||
@@ -95,8 +95,8 @@ pub struct MailingIdentity {
|
||||
}
|
||||
|
||||
impl MailingIdentity {
|
||||
pub fn from_env() -> dotenvy::Result<Self> {
|
||||
Ok(Self {
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
from_name: ENV.SMTP_FROM_NAME.clone(),
|
||||
from_address: ENV.SMTP_FROM_ADDRESS.clone(),
|
||||
reply_name: if ENV.SMTP_REPLY_TO_NAME.is_empty() {
|
||||
@@ -109,7 +109,7 @@ impl MailingIdentity {
|
||||
} else {
|
||||
Some(ENV.SMTP_REPLY_TO_ADDRESS.clone())
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::models::pats::Scopes;
|
||||
use crate::queue::analytics::AnalyticsQueue;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use crate::search::SearchConfig;
|
||||
use crate::search::SearchBackend;
|
||||
use crate::util::date::get_current_tenths_of_ms;
|
||||
use crate::util::guards::admin_key_guard;
|
||||
use actix_web::{HttpRequest, HttpResponse, patch, post, web};
|
||||
@@ -152,10 +152,12 @@ pub async fn count_download(
|
||||
pub async fn force_reindex(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
config: web::Data<SearchConfig>,
|
||||
search_backend: web::Data<dyn SearchBackend>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
use crate::search::indexing::index_projects;
|
||||
let redis = redis.get_ref();
|
||||
index_projects(pool.as_ref().clone(), redis.clone(), &config).await?;
|
||||
search_backend
|
||||
.index_projects(pool.as_ref().clone(), redis.clone())
|
||||
.await
|
||||
.map_err(ApiError::Internal)?;
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
use crate::routes::ApiError;
|
||||
use crate::search::SearchConfig;
|
||||
use crate::util::guards::admin_key_guard;
|
||||
use actix_web::{HttpResponse, delete, get, web};
|
||||
use meilisearch_sdk::tasks::{Task, TasksCancelQuery};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use utoipa::ToSchema;
|
||||
use crate::{
|
||||
routes::ApiError,
|
||||
search::{SearchBackend, TasksCancelFilter},
|
||||
};
|
||||
use actix_web::{delete, get, web};
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
cfg.service(tasks).service(tasks_cancel);
|
||||
@@ -15,107 +12,20 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
#[utoipa::path]
|
||||
#[get("tasks", guard = "admin_key_guard")]
|
||||
pub async fn tasks(
|
||||
config: web::Data<SearchConfig>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let client = config.make_batch_client()?;
|
||||
let tasks = client
|
||||
.with_all_clients("get_tasks", async |client| {
|
||||
let tasks = client.get_tasks().await?;
|
||||
|
||||
Ok(tasks.results)
|
||||
})
|
||||
.await?;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
struct MeiliTask<Time> {
|
||||
uid: u32,
|
||||
status: &'static str,
|
||||
duration: Option<Duration>,
|
||||
enqueued_at: Option<Time>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
struct TaskList<Time> {
|
||||
by_instance: HashMap<String, Vec<MeiliTask<Time>>>,
|
||||
}
|
||||
|
||||
let response = tasks
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, instance_tasks)| {
|
||||
let tasks = instance_tasks
|
||||
.into_iter()
|
||||
.filter_map(|task| {
|
||||
Some(match task {
|
||||
Task::Enqueued { content } => MeiliTask {
|
||||
uid: content.uid,
|
||||
status: "enqueued",
|
||||
duration: None,
|
||||
enqueued_at: Some(content.enqueued_at),
|
||||
},
|
||||
Task::Processing { content } => MeiliTask {
|
||||
uid: content.uid,
|
||||
status: "processing",
|
||||
duration: None,
|
||||
enqueued_at: Some(content.enqueued_at),
|
||||
},
|
||||
Task::Failed { content } => MeiliTask {
|
||||
uid: content.task.uid,
|
||||
status: "failed",
|
||||
duration: Some(content.task.duration),
|
||||
enqueued_at: Some(content.task.enqueued_at),
|
||||
},
|
||||
Task::Succeeded { content: _ } => return None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
(idx.to_string(), tasks)
|
||||
})
|
||||
.collect::<HashMap<String, Vec<MeiliTask<_>>>>();
|
||||
|
||||
Ok(HttpResponse::Ok().json(TaskList {
|
||||
by_instance: response,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum TasksCancelFilter {
|
||||
All,
|
||||
AllEnqueued,
|
||||
Indexes { indexes: Vec<String> },
|
||||
search: web::Data<dyn SearchBackend>,
|
||||
) -> Result<web::Json<serde_json::Value>, ApiError> {
|
||||
Ok(web::Json(search.tasks().await.map_err(ApiError::Internal)?))
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[delete("tasks", guard = "admin_key_guard")]
|
||||
pub async fn tasks_cancel(
|
||||
config: web::Data<SearchConfig>,
|
||||
search: web::Data<dyn SearchBackend>,
|
||||
body: web::Json<TasksCancelFilter>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let client = config.make_batch_client()?;
|
||||
let all_results = client
|
||||
.with_all_clients("cancel_tasks", async |client| {
|
||||
let mut q = TasksCancelQuery::new(client);
|
||||
match &body.0 {
|
||||
TasksCancelFilter::All => {}
|
||||
TasksCancelFilter::Indexes { indexes } => {
|
||||
q.with_index_uids(indexes.iter().map(|s| s.as_str()));
|
||||
}
|
||||
TasksCancelFilter::AllEnqueued => {
|
||||
q.with_statuses(["enqueued"]);
|
||||
}
|
||||
};
|
||||
|
||||
let result = client.cancel_tasks_with(&q).await;
|
||||
|
||||
Ok(result)
|
||||
})
|
||||
.await?;
|
||||
|
||||
for r in all_results {
|
||||
r?;
|
||||
}
|
||||
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
) -> Result<(), ApiError> {
|
||||
search
|
||||
.tasks_cancel(&body)
|
||||
.await
|
||||
.map_err(ApiError::Internal)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -95,8 +95,6 @@ pub enum ApiError {
|
||||
Auth(eyre::Report),
|
||||
#[error("Invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
#[error("Environment error")]
|
||||
Env(#[from] dotenvy::Error),
|
||||
#[error("Error while uploading file: {0}")]
|
||||
FileHosting(#[from] FileHostingError),
|
||||
#[error("database error")]
|
||||
@@ -119,8 +117,6 @@ pub enum ApiError {
|
||||
Validation(String),
|
||||
#[error("Search error: {0}")]
|
||||
Search(#[from] meilisearch_sdk::errors::Error),
|
||||
#[error("search indexing error")]
|
||||
Indexing(#[from] crate::search::indexing::IndexingError),
|
||||
#[error("Payments error: {0}")]
|
||||
Payments(String),
|
||||
#[error("Discord error: {0}")]
|
||||
@@ -178,7 +174,6 @@ impl ApiError {
|
||||
Self::Internal(..) => "internal_error",
|
||||
Self::Request(..) => "request_error",
|
||||
Self::Auth(..) => "auth_error",
|
||||
Self::Env(..) => "environment_error",
|
||||
Self::Database(..) => "database_error",
|
||||
Self::SqlxDatabase(..) => "database_error",
|
||||
Self::RedisDatabase(..) => "database_error",
|
||||
@@ -187,7 +182,6 @@ impl ApiError {
|
||||
Self::Xml(..) => "xml_error",
|
||||
Self::Json(..) => "json_error",
|
||||
Self::Search(..) => "search_error",
|
||||
Self::Indexing(..) => "indexing_error",
|
||||
Self::FileHosting(..) => "file_hosting_error",
|
||||
Self::InvalidInput(..) => "invalid_input",
|
||||
Self::Validation(..) => "invalid_input",
|
||||
@@ -243,7 +237,6 @@ impl actix_web::ResponseError for ApiError {
|
||||
Self::Request(..) => StatusCode::BAD_REQUEST,
|
||||
Self::Auth(..) => StatusCode::UNAUTHORIZED,
|
||||
Self::InvalidInput(..) => StatusCode::BAD_REQUEST,
|
||||
Self::Env(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Database(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::SqlxDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::RedisDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -253,7 +246,6 @@ impl actix_web::ResponseError for ApiError {
|
||||
Self::Xml(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Json(..) => StatusCode::BAD_REQUEST,
|
||||
Self::Search(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Indexing(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::FileHosting(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Validation(..) => StatusCode::BAD_REQUEST,
|
||||
Self::Payments(..) => StatusCode::FAILED_DEPENDENCY,
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::queue::moderation::AutomatedModerationQueue;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::v3::projects::ProjectIds;
|
||||
use crate::routes::{ApiError, v2_reroute, v3};
|
||||
use crate::search::{SearchConfig, SearchError, search_for_project};
|
||||
use crate::search::SearchBackend;
|
||||
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -53,8 +53,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
#[get("search")]
|
||||
pub async fn project_search(
|
||||
web::Query(info): web::Query<SearchRequest>,
|
||||
config: web::Data<SearchConfig>,
|
||||
) -> Result<HttpResponse, SearchError> {
|
||||
search_backend: web::Data<dyn SearchBackend>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
// Search now uses loader_fields instead of explicit 'client_side' and 'server_side' fields
|
||||
// While the backend for this has changed, it doesnt affect much
|
||||
// in the API calls except that 'versions:x' is now 'game_versions:x'
|
||||
@@ -99,7 +99,10 @@ pub async fn project_search(
|
||||
..info
|
||||
};
|
||||
|
||||
let results = search_for_project(&info, &config).await?;
|
||||
let results = search_backend
|
||||
.search_for_project(&info)
|
||||
.await
|
||||
.map_err(ApiError::Internal)?;
|
||||
|
||||
let results = LegacySearchResults::from(results);
|
||||
|
||||
@@ -411,7 +414,7 @@ pub async fn project_edit(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
search_config: web::Data<SearchConfig>,
|
||||
search_backend: web::Data<dyn SearchBackend>,
|
||||
new_project: web::Json<EditProject>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
@@ -521,7 +524,7 @@ pub async fn project_edit(
|
||||
req.clone(),
|
||||
info,
|
||||
pool.clone(),
|
||||
search_config,
|
||||
search_backend,
|
||||
web::Json(new_project),
|
||||
redis.clone(),
|
||||
session_queue.clone(),
|
||||
@@ -915,7 +918,7 @@ pub async fn project_delete(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
search_config: web::Data<SearchConfig>,
|
||||
search_backend: web::Data<dyn SearchBackend>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
// Returns NoContent, so no need to convert
|
||||
@@ -924,7 +927,7 @@ pub async fn project_delete(
|
||||
info,
|
||||
pool,
|
||||
redis,
|
||||
search_config,
|
||||
search_backend,
|
||||
session_queue,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::models::projects::{
|
||||
use crate::models::v2::projects::LegacyVersion;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::{v2_reroute, v3};
|
||||
use crate::search::SearchConfig;
|
||||
use crate::search::SearchBackend;
|
||||
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use validator::Validate;
|
||||
@@ -349,7 +349,7 @@ pub async fn version_delete(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
search_config: web::Data<SearchConfig>,
|
||||
search_backend: web::Data<dyn SearchBackend>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
// Returns NoContent, so we don't need to convert the response
|
||||
v3::versions::version_delete(
|
||||
@@ -358,7 +358,7 @@ pub async fn version_delete(
|
||||
pool,
|
||||
redis,
|
||||
session_queue,
|
||||
search_config,
|
||||
search_backend,
|
||||
)
|
||||
.await
|
||||
.or_else(v2_reroute::flatten_404_error)
|
||||
|
||||
@@ -21,7 +21,6 @@ use crate::models::teams::{OrganizationPermissions, ProjectPermissions};
|
||||
use crate::models::threads::ThreadType;
|
||||
use crate::models::v3::user_limits::UserLimits;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::search::indexing::IndexingError;
|
||||
use crate::util::guards::admin_key_guard;
|
||||
use crate::util::img::upload_image_optimized;
|
||||
use crate::util::routes::read_from_field;
|
||||
@@ -49,14 +48,10 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum CreateError {
|
||||
#[error("Environment Error")]
|
||||
EnvError(#[from] dotenvy::Error),
|
||||
#[error("An unknown database error occurred")]
|
||||
SqlxDatabaseError(#[from] sqlx::Error),
|
||||
#[error("Database Error: {0}")]
|
||||
DatabaseError(#[from] models::DatabaseError),
|
||||
#[error("Indexing Error: {0}")]
|
||||
IndexingError(#[from] IndexingError),
|
||||
#[error("Error while parsing multipart payload: {0}")]
|
||||
MultipartError(#[from] actix_multipart::MultipartError),
|
||||
#[error("Error while parsing JSON: {0}")]
|
||||
@@ -96,12 +91,10 @@ pub enum CreateError {
|
||||
impl actix_web::ResponseError for CreateError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
CreateError::EnvError(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
CreateError::SqlxDatabaseError(..) => {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
CreateError::DatabaseError(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
CreateError::IndexingError(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
CreateError::FileHostingError(..) => {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
@@ -129,10 +122,8 @@ impl actix_web::ResponseError for CreateError {
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
HttpResponse::build(self.status_code()).json(ApiError {
|
||||
error: match self {
|
||||
CreateError::EnvError(..) => "environment_error",
|
||||
CreateError::SqlxDatabaseError(..) => "database_error",
|
||||
CreateError::DatabaseError(..) => "database_error",
|
||||
CreateError::IndexingError(..) => "indexing_error",
|
||||
CreateError::FileHostingError(..) => "file_hosting_error",
|
||||
CreateError::SerDeError(..) => "invalid_input",
|
||||
CreateError::MultipartError(..) => "invalid_input",
|
||||
|
||||
@@ -29,8 +29,7 @@ use crate::queue::moderation::AutomatedModerationQueue;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use crate::routes::internal::delphi;
|
||||
use crate::search::indexing::remove_documents;
|
||||
use crate::search::{SearchConfig, SearchError, search_for_project};
|
||||
use crate::search::{SearchBackend, SearchResults};
|
||||
use crate::util::error::Context;
|
||||
use crate::util::img;
|
||||
use crate::util::img::{delete_old_images, upload_image_optimized};
|
||||
@@ -264,7 +263,7 @@ pub async fn project_edit(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
search_config: web::Data<SearchConfig>,
|
||||
search_backend: web::Data<dyn SearchBackend>,
|
||||
new_project: web::Json<EditProject>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
@@ -974,16 +973,16 @@ pub async fn project_edit(
|
||||
project_item.inner.status.is_searchable(),
|
||||
new_project.status.map(|status| status.is_searchable()),
|
||||
) {
|
||||
remove_documents(
|
||||
&project_item
|
||||
.versions
|
||||
.into_iter()
|
||||
.map(|x| x.into())
|
||||
.collect::<Vec<_>>(),
|
||||
&search_config,
|
||||
)
|
||||
.await
|
||||
.wrap_internal_err("failed to remove documents")?;
|
||||
search_backend
|
||||
.remove_documents(
|
||||
&project_item
|
||||
.versions
|
||||
.into_iter()
|
||||
.map(|x| x.into())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await
|
||||
.wrap_internal_err("failed to remove documents")?;
|
||||
}
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
@@ -1039,9 +1038,12 @@ pub async fn edit_project_categories(
|
||||
|
||||
pub async fn project_search(
|
||||
web::Query(info): web::Query<SearchRequest>,
|
||||
config: web::Data<SearchConfig>,
|
||||
) -> Result<HttpResponse, SearchError> {
|
||||
let results = search_for_project(&info, &config).await?;
|
||||
search_backend: web::Data<dyn SearchBackend>,
|
||||
) -> Result<web::Json<SearchResults>, ApiError> {
|
||||
let results = search_backend
|
||||
.search_for_project(&info)
|
||||
.await
|
||||
.map_err(ApiError::Internal)?;
|
||||
|
||||
// TODO: add this back
|
||||
// let results = ReturnSearchResults {
|
||||
@@ -1055,7 +1057,7 @@ pub async fn project_search(
|
||||
// total_hits: results.total_hits,
|
||||
// };
|
||||
|
||||
Ok(HttpResponse::Ok().json(results))
|
||||
Ok(web::Json(results))
|
||||
}
|
||||
|
||||
//checks the validity of a project id or slug
|
||||
@@ -2158,7 +2160,7 @@ pub async fn project_delete(
|
||||
info: web::Path<(String,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
search_config: web::Data<SearchConfig>,
|
||||
search_backend: web::Data<dyn SearchBackend>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<(), ApiError> {
|
||||
let (_, user) = get_user_from_headers(
|
||||
@@ -2270,16 +2272,16 @@ pub async fn project_delete(
|
||||
.await
|
||||
.wrap_internal_err("failed to commit transaction")?;
|
||||
|
||||
remove_documents(
|
||||
&project
|
||||
.versions
|
||||
.into_iter()
|
||||
.map(|x| x.into())
|
||||
.collect::<Vec<_>>(),
|
||||
&search_config,
|
||||
)
|
||||
.await
|
||||
.wrap_internal_err("failed to remove project version documents")?;
|
||||
search_backend
|
||||
.remove_documents(
|
||||
&project
|
||||
.versions
|
||||
.into_iter()
|
||||
.map(|x| x.into())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await
|
||||
.wrap_internal_err("failed to remove project version documents")?;
|
||||
|
||||
if result.is_some() {
|
||||
Ok(())
|
||||
|
||||
@@ -26,8 +26,7 @@ use crate::models::projects::{Loader, skip_nulls};
|
||||
use crate::models::teams::ProjectPermissions;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::internal::delphi;
|
||||
use crate::search::SearchConfig;
|
||||
use crate::search::indexing::remove_documents;
|
||||
use crate::search::SearchBackend;
|
||||
use crate::util::error::Context;
|
||||
use crate::util::img;
|
||||
use crate::util::validate::validation_errors_to_string;
|
||||
@@ -894,7 +893,7 @@ pub async fn version_delete(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
search_config: web::Data<SearchConfig>,
|
||||
search_backend: web::Data<dyn SearchBackend>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
@@ -1001,10 +1000,10 @@ pub async fn version_delete(
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
remove_documents(&[version.inner.id.into()], &search_config)
|
||||
search_backend
|
||||
.remove_documents(&[version.inner.id.into()])
|
||||
.await
|
||||
.wrap_internal_err("failed to remove documents")?;
|
||||
|
||||
if result.is_some() {
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+11
-11
@@ -1,18 +1,16 @@
|
||||
/// This module is used for the indexing from any source.
|
||||
pub mod local_import;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::database::PgPool;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::env::ENV;
|
||||
use crate::search::{SearchConfig, UploadSearchProject};
|
||||
use crate::search::UploadSearchProject;
|
||||
use crate::search::backend::meilisearch::MeilisearchConfig;
|
||||
use crate::search::indexing::index_local;
|
||||
use crate::util::error::Context;
|
||||
use ariadne::ids::base62_impl::to_base62;
|
||||
use eyre::eyre;
|
||||
use futures::StreamExt;
|
||||
use futures::stream::FuturesOrdered;
|
||||
use local_import::index_local;
|
||||
use meilisearch_sdk::client::{Client, SwapIndexes};
|
||||
use meilisearch_sdk::indexes::Index;
|
||||
use meilisearch_sdk::settings::{PaginationSetting, Settings};
|
||||
@@ -34,6 +32,8 @@ pub enum IndexingError {
|
||||
Env(#[from] dotenvy::Error),
|
||||
#[error("Error while awaiting index creation task")]
|
||||
Task,
|
||||
#[error(transparent)]
|
||||
Other(#[from] eyre::Report),
|
||||
}
|
||||
|
||||
// // The chunk size for adding projects to the indexing database. If the request size
|
||||
@@ -49,8 +49,8 @@ fn search_operation_timeout() -> std::time::Duration {
|
||||
|
||||
pub async fn remove_documents(
|
||||
ids: &[crate::models::ids::VersionId],
|
||||
config: &SearchConfig,
|
||||
) -> eyre::Result<()> {
|
||||
config: &MeilisearchConfig,
|
||||
) -> Result<(), IndexingError> {
|
||||
let mut indexes = get_indexes_for_indexing(config, false, false)
|
||||
.await
|
||||
.wrap_err("failed to get current indexes")?;
|
||||
@@ -106,7 +106,7 @@ pub async fn remove_documents(
|
||||
pub async fn index_projects(
|
||||
ro_pool: PgPool,
|
||||
redis: RedisPool,
|
||||
config: &SearchConfig,
|
||||
config: &MeilisearchConfig,
|
||||
) -> Result<(), IndexingError> {
|
||||
info!("Indexing projects.");
|
||||
|
||||
@@ -188,7 +188,7 @@ pub async fn index_projects(
|
||||
}
|
||||
|
||||
pub async fn swap_index(
|
||||
config: &SearchConfig,
|
||||
config: &MeilisearchConfig,
|
||||
index_name: &str,
|
||||
) -> Result<(), IndexingError> {
|
||||
let client = config.make_batch_client()?;
|
||||
@@ -224,7 +224,7 @@ pub async fn swap_index(
|
||||
|
||||
#[instrument(skip(config))]
|
||||
pub async fn get_indexes_for_indexing(
|
||||
config: &SearchConfig,
|
||||
config: &MeilisearchConfig,
|
||||
next: bool, // Get the 'next' one
|
||||
update_settings: bool,
|
||||
) -> Result<Vec<Vec<Index>>, IndexingError> {
|
||||
@@ -500,7 +500,7 @@ pub async fn add_projects_batch_client(
|
||||
indices: &[Vec<Index>],
|
||||
projects: Vec<UploadSearchProject>,
|
||||
additional_fields: Vec<String>,
|
||||
config: &SearchConfig,
|
||||
config: &MeilisearchConfig,
|
||||
) -> Result<(), IndexingError> {
|
||||
let client = config.make_batch_client()?;
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
use crate::database::PgPool;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::env::ENV;
|
||||
use crate::models::ids::VersionId;
|
||||
use crate::models::projects::SearchRequest;
|
||||
use crate::routes::ApiError;
|
||||
use crate::search::{
|
||||
ResultSearchProject, SearchBackend, SearchResults, TasksCancelFilter,
|
||||
};
|
||||
use crate::util::error::Context;
|
||||
use async_trait::async_trait;
|
||||
use eyre::eyre;
|
||||
use futures::TryStreamExt;
|
||||
use futures::stream::FuturesOrdered;
|
||||
use itertools::Itertools;
|
||||
use meilisearch_sdk::client::Client;
|
||||
use meilisearch_sdk::tasks::{Task, TasksCancelQuery};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write;
|
||||
use std::time::Duration;
|
||||
use tracing::{Instrument, info_span};
|
||||
|
||||
pub mod indexing;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MeilisearchReadClient {
|
||||
pub client: Client,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for MeilisearchReadClient {
|
||||
type Target = Client;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.client
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BatchClient {
|
||||
pub clients: Vec<Client>,
|
||||
}
|
||||
|
||||
impl BatchClient {
|
||||
pub fn new(clients: Vec<Client>) -> Self {
|
||||
Self { clients }
|
||||
}
|
||||
|
||||
pub async fn with_all_clients<'a, T, G, Fut>(
|
||||
&'a self,
|
||||
task_name: &str,
|
||||
generator: G,
|
||||
) -> Result<Vec<T>, indexing::IndexingError>
|
||||
where
|
||||
G: Fn(&'a Client) -> Fut,
|
||||
Fut: Future<Output = Result<T, indexing::IndexingError>> + 'a,
|
||||
{
|
||||
let mut tasks = FuturesOrdered::new();
|
||||
for (idx, client) in self.clients.iter().enumerate() {
|
||||
tasks.push_back(generator(client).instrument(info_span!(
|
||||
"client_task",
|
||||
task.name = task_name,
|
||||
client.idx = idx,
|
||||
)));
|
||||
}
|
||||
|
||||
let results = tasks.try_collect::<Vec<T>>().await?;
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
pub fn across_all<T, F, R>(&self, data: Vec<T>, mut predicate: F) -> Vec<R>
|
||||
where
|
||||
F: FnMut(T, &Client) -> R,
|
||||
{
|
||||
assert_eq!(
|
||||
data.len(),
|
||||
self.clients.len(),
|
||||
"mismatch between data len and meilisearch client count"
|
||||
);
|
||||
self.clients
|
||||
.iter()
|
||||
.zip(data)
|
||||
.map(|(client, item)| predicate(item, client))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MeilisearchConfig {
|
||||
pub addresses: Vec<String>,
|
||||
pub read_lb_address: String,
|
||||
pub key: String,
|
||||
pub meta_namespace: String,
|
||||
}
|
||||
|
||||
impl MeilisearchConfig {
|
||||
pub fn new(meta_namespace: Option<String>) -> Self {
|
||||
Self {
|
||||
addresses: ENV.MEILISEARCH_WRITE_ADDRS.0.clone(),
|
||||
key: ENV.MEILISEARCH_KEY.clone(),
|
||||
meta_namespace: meta_namespace.unwrap_or_default(),
|
||||
read_lb_address: ENV.MEILISEARCH_READ_ADDR.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn make_loadbalanced_read_client(
|
||||
&self,
|
||||
) -> Result<MeilisearchReadClient, meilisearch_sdk::errors::Error> {
|
||||
Ok(MeilisearchReadClient {
|
||||
client: Client::new(&self.read_lb_address, Some(&self.key))?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn make_batch_client(
|
||||
&self,
|
||||
) -> Result<BatchClient, meilisearch_sdk::errors::Error> {
|
||||
Ok(BatchClient::new(
|
||||
self.addresses
|
||||
.iter()
|
||||
.map(|address| {
|
||||
Client::new(address.as_str(), Some(self.key.as_str()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn get_index_name(&self, index: &str, next: bool) -> String {
|
||||
let alt = if next { "_alt" } else { "" };
|
||||
format!("{}_{}_{}", self.meta_namespace, index, alt)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Meilisearch {
|
||||
pub config: MeilisearchConfig,
|
||||
}
|
||||
|
||||
impl Meilisearch {
|
||||
pub fn new(config: MeilisearchConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
fn get_sort_index(
|
||||
&self,
|
||||
index: &str,
|
||||
) -> Result<(String, &'static [&'static str]), ApiError> {
|
||||
let projects_name = self.config.get_index_name("projects", false);
|
||||
let projects_filtered_name =
|
||||
self.config.get_index_name("projects_filtered", false);
|
||||
Ok(match index {
|
||||
"relevance" => (projects_name, &["downloads:desc"]),
|
||||
"downloads" => (projects_filtered_name, &["downloads:desc"]),
|
||||
"follows" => (projects_name, &["follows:desc"]),
|
||||
"updated" => (projects_name, &["date_modified:desc"]),
|
||||
"newest" => (projects_name, &["date_created:desc"]),
|
||||
_ => {
|
||||
return Err(ApiError::Request(eyre!(
|
||||
"invalid index `{index}`"
|
||||
)));
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SearchBackend for Meilisearch {
|
||||
async fn search_for_project(
|
||||
&self,
|
||||
info: &SearchRequest,
|
||||
) -> eyre::Result<SearchResults> {
|
||||
let offset: usize = info
|
||||
.offset
|
||||
.as_deref()
|
||||
.unwrap_or("0")
|
||||
.parse()
|
||||
.wrap_request_err("invalid offset")?;
|
||||
let index = info.index.as_deref().unwrap_or("relevance");
|
||||
let limit = info
|
||||
.limit
|
||||
.as_deref()
|
||||
.unwrap_or("10")
|
||||
.parse::<usize>()
|
||||
.wrap_request_err("invalid limit")?
|
||||
.min(100);
|
||||
|
||||
let (index_name, sort_name) = self.get_sort_index(index)?;
|
||||
let client = self
|
||||
.config
|
||||
.make_loadbalanced_read_client()
|
||||
.wrap_internal_err("failed to make load-balanced read client")?;
|
||||
let meilisearch_index = client
|
||||
.get_index(index_name)
|
||||
.await
|
||||
.wrap_internal_err("failed to get index")?;
|
||||
|
||||
let mut filter_string = String::new();
|
||||
|
||||
let hits_per_page = if limit == 0 { 1 } else { limit };
|
||||
|
||||
let page = offset / hits_per_page + 1;
|
||||
|
||||
let results = {
|
||||
let mut query = meilisearch_index.search();
|
||||
query
|
||||
.with_page(page)
|
||||
.with_hits_per_page(hits_per_page)
|
||||
.with_query(info.query.as_deref().unwrap_or_default())
|
||||
.with_sort(sort_name);
|
||||
|
||||
if let Some(new_filters) = info.new_filters.as_deref() {
|
||||
query.with_filter(new_filters);
|
||||
} else {
|
||||
let facets = if let Some(facets) = &info.facets {
|
||||
let facets =
|
||||
serde_json::from_str::<Vec<Vec<Value>>>(facets)
|
||||
.wrap_request_err("failed to parse facets")?;
|
||||
Some(facets)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let filters: Cow<_> =
|
||||
match (info.filters.as_deref(), info.version.as_deref()) {
|
||||
(Some(f), Some(v)) => format!("({f}) AND ({v})").into(),
|
||||
(Some(f), None) => f.into(),
|
||||
(None, Some(v)) => v.into(),
|
||||
(None, None) => "".into(),
|
||||
};
|
||||
|
||||
if let Some(facets) = facets {
|
||||
let facets: Vec<Vec<Vec<String>>> =
|
||||
facets
|
||||
.into_iter()
|
||||
.map(|facets| {
|
||||
facets
|
||||
.into_iter()
|
||||
.map(|facet| {
|
||||
if facet.is_array() {
|
||||
serde_json::from_value::<Vec<String>>(facet)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
vec![
|
||||
serde_json::from_value::<String>(facet)
|
||||
.unwrap_or_default(),
|
||||
]
|
||||
}
|
||||
})
|
||||
.collect_vec()
|
||||
})
|
||||
.collect_vec();
|
||||
|
||||
filter_string.push('(');
|
||||
for (index, facet_outer_list) in facets.iter().enumerate() {
|
||||
filter_string.push('(');
|
||||
|
||||
for (facet_outer_index, facet_inner_list) in
|
||||
facet_outer_list.iter().enumerate()
|
||||
{
|
||||
filter_string.push('(');
|
||||
for (facet_inner_index, facet) in
|
||||
facet_inner_list.iter().enumerate()
|
||||
{
|
||||
filter_string
|
||||
.push_str(&facet.replace(':', " = "));
|
||||
if facet_inner_index
|
||||
!= (facet_inner_list.len() - 1)
|
||||
{
|
||||
filter_string.push_str(" AND ")
|
||||
}
|
||||
}
|
||||
filter_string.push(')');
|
||||
|
||||
if facet_outer_index != (facet_outer_list.len() - 1)
|
||||
{
|
||||
filter_string.push_str(" OR ")
|
||||
}
|
||||
}
|
||||
|
||||
filter_string.push(')');
|
||||
|
||||
if index != (facets.len() - 1) {
|
||||
filter_string.push_str(" AND ")
|
||||
}
|
||||
}
|
||||
filter_string.push(')');
|
||||
|
||||
if !filters.is_empty() {
|
||||
write!(filter_string, " AND ({filters})")
|
||||
.expect("write should not fail");
|
||||
}
|
||||
} else {
|
||||
filter_string.push_str(&filters);
|
||||
}
|
||||
|
||||
if !filter_string.is_empty() {
|
||||
query.with_filter(&filter_string);
|
||||
}
|
||||
}
|
||||
|
||||
query.execute::<ResultSearchProject>().await?
|
||||
};
|
||||
|
||||
Ok(SearchResults {
|
||||
hits: results.hits.into_iter().map(|r| r.result).collect(),
|
||||
page: results.page.unwrap_or_default(),
|
||||
hits_per_page: results.hits_per_page.unwrap_or_default(),
|
||||
total_hits: results.total_hits.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn index_projects(
|
||||
&self,
|
||||
ro_pool: PgPool,
|
||||
redis: RedisPool,
|
||||
) -> eyre::Result<()> {
|
||||
indexing::index_projects(ro_pool, redis, &self.config).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_documents(&self, ids: &[VersionId]) -> eyre::Result<()> {
|
||||
indexing::remove_documents(ids, &self.config).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn tasks(&self) -> eyre::Result<Value> {
|
||||
let client = self
|
||||
.config
|
||||
.make_batch_client()
|
||||
.wrap_internal_err("failed to make batch client")?;
|
||||
let tasks = client
|
||||
.with_all_clients("get_tasks", async |client| {
|
||||
let tasks = client.get_tasks().await?;
|
||||
Ok(tasks.results)
|
||||
})
|
||||
.await
|
||||
.wrap_internal_err("failed to get tasks")?;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MeiliTask<Time> {
|
||||
uid: u32,
|
||||
status: &'static str,
|
||||
duration: Option<Duration>,
|
||||
enqueued_at: Option<Time>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TaskList<Time> {
|
||||
by_instance: HashMap<String, Vec<MeiliTask<Time>>>,
|
||||
}
|
||||
|
||||
let response = tasks
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, instance_tasks)| {
|
||||
let tasks = instance_tasks
|
||||
.into_iter()
|
||||
.filter_map(|task| {
|
||||
Some(match task {
|
||||
Task::Enqueued { content } => MeiliTask {
|
||||
uid: content.uid,
|
||||
status: "enqueued",
|
||||
duration: None,
|
||||
enqueued_at: Some(content.enqueued_at),
|
||||
},
|
||||
Task::Processing { content } => MeiliTask {
|
||||
uid: content.uid,
|
||||
status: "processing",
|
||||
duration: None,
|
||||
enqueued_at: Some(content.enqueued_at),
|
||||
},
|
||||
Task::Failed { content } => MeiliTask {
|
||||
uid: content.task.uid,
|
||||
status: "failed",
|
||||
duration: Some(content.task.duration),
|
||||
enqueued_at: Some(content.task.enqueued_at),
|
||||
},
|
||||
Task::Succeeded { .. } => return None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
(idx.to_string(), tasks)
|
||||
})
|
||||
.collect::<HashMap<String, Vec<MeiliTask<_>>>>();
|
||||
|
||||
let response = serde_json::to_value(TaskList {
|
||||
by_instance: response,
|
||||
})
|
||||
.wrap_internal_err("failed to serialize tasks response")?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn tasks_cancel(
|
||||
&self,
|
||||
filter: &TasksCancelFilter,
|
||||
) -> eyre::Result<()> {
|
||||
let client = self
|
||||
.config
|
||||
.make_batch_client()
|
||||
.wrap_internal_err("failed to make batch client")?;
|
||||
let all_results = client
|
||||
.with_all_clients("cancel_tasks", async |client| {
|
||||
let mut q = TasksCancelQuery::new(client);
|
||||
match filter {
|
||||
TasksCancelFilter::All => {}
|
||||
TasksCancelFilter::Indexes { indexes } => {
|
||||
q.with_index_uids(indexes.iter().map(|s| s.as_str()));
|
||||
}
|
||||
TasksCancelFilter::AllEnqueued => {
|
||||
q.with_statuses(["enqueued"]);
|
||||
}
|
||||
};
|
||||
|
||||
let result = client.cancel_tasks_with(&q).await;
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
.wrap_internal_err("failed to cancel tasks")?;
|
||||
|
||||
for r in all_results {
|
||||
r.wrap_internal_err("failed to cancel tasks")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod elasticsearch;
|
||||
mod meilisearch;
|
||||
|
||||
pub use elasticsearch::Elasticsearch;
|
||||
pub use meilisearch::{Meilisearch, MeilisearchConfig};
|
||||
+13
-7
@@ -5,7 +5,6 @@ use itertools::Itertools;
|
||||
use std::collections::HashMap;
|
||||
use tracing::info;
|
||||
|
||||
use super::IndexingError;
|
||||
use crate::database::PgPool;
|
||||
use crate::database::models::loader_fields::{
|
||||
QueryLoaderField, QueryLoaderFieldEnumValue, QueryVersionField,
|
||||
@@ -19,12 +18,13 @@ use crate::models::projects::from_duplicate_version_fields;
|
||||
use crate::models::v2::projects::LegacyProject;
|
||||
use crate::routes::v2_reroute;
|
||||
use crate::search::UploadSearchProject;
|
||||
use crate::util::error::Context;
|
||||
|
||||
pub async fn index_local(
|
||||
pool: &PgPool,
|
||||
cursor: i64,
|
||||
limit: i64,
|
||||
) -> Result<(Vec<UploadSearchProject>, i64), IndexingError> {
|
||||
) -> eyre::Result<(Vec<UploadSearchProject>, i64)> {
|
||||
info!("Indexing local projects!");
|
||||
|
||||
// todo: loaders, project type, game versions
|
||||
@@ -76,7 +76,8 @@ pub async fn index_local(
|
||||
}
|
||||
})
|
||||
.try_collect::<Vec<PartialProject>>()
|
||||
.await?;
|
||||
.await
|
||||
.wrap_err("failed to fetch projects")?;
|
||||
|
||||
let project_ids = db_projects.iter().map(|x| x.id.0).collect::<Vec<i64>>();
|
||||
|
||||
@@ -438,7 +439,7 @@ struct PartialVersion {
|
||||
async fn index_versions(
|
||||
pool: &PgPool,
|
||||
project_ids: Vec<i64>,
|
||||
) -> Result<HashMap<DBProjectId, Vec<PartialVersion>>, IndexingError> {
|
||||
) -> eyre::Result<HashMap<DBProjectId, Vec<PartialVersion>>> {
|
||||
let versions: HashMap<DBProjectId, Vec<DBVersionId>> = sqlx::query!(
|
||||
"
|
||||
SELECT v.id, v.mod_id
|
||||
@@ -457,7 +458,8 @@ async fn index_versions(
|
||||
async move { Ok(acc) }
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
.wrap_err("failed to fetch versions")?;
|
||||
|
||||
// Get project types, loaders
|
||||
#[derive(Default)]
|
||||
@@ -498,7 +500,8 @@ async fn index_versions(
|
||||
(version_id, version_loader_data)
|
||||
})
|
||||
.try_collect()
|
||||
.await?;
|
||||
.await
|
||||
.wrap_err("failed to fetch loaders and project types")?;
|
||||
|
||||
// Get version fields
|
||||
let version_fields: DashMap<DBVersionId, Vec<QueryVersionField>> =
|
||||
@@ -530,7 +533,10 @@ async fn index_versions(
|
||||
async move { Ok(acc) }
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
.wrap_err("failed to fetch version fields")?;
|
||||
|
||||
// Get version fields
|
||||
|
||||
// Convert to partial versions
|
||||
let mut res_versions: HashMap<DBProjectId, Vec<PartialVersion>> =
|
||||
+62
-302
@@ -1,178 +1,71 @@
|
||||
use crate::env::ENV;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::ids::VersionId;
|
||||
use crate::models::projects::SearchRequest;
|
||||
use crate::{models::error::ApiError, search::indexing::IndexingError};
|
||||
use actix_web::HttpResponse;
|
||||
use actix_web::http::StatusCode;
|
||||
use crate::{database::PgPool, env::ENV};
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::TryStreamExt;
|
||||
use futures::stream::FuturesOrdered;
|
||||
use itertools::Itertools;
|
||||
use meilisearch_sdk::client::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write;
|
||||
use std::{collections::HashMap, str::FromStr};
|
||||
use thiserror::Error;
|
||||
use tracing::{Instrument, info_span};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
pub mod backend;
|
||||
pub mod indexing;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SearchError {
|
||||
#[error("MeiliSearch Error: {0}")]
|
||||
MeiliSearch(#[from] meilisearch_sdk::errors::Error),
|
||||
#[error("Error while serializing or deserializing JSON: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
#[error("Error while parsing an integer: {0}")]
|
||||
IntParsing(#[from] std::num::ParseIntError),
|
||||
#[error("Error while formatting strings: {0}")]
|
||||
FormatError(#[from] std::fmt::Error),
|
||||
#[error("Environment Error")]
|
||||
Env(#[from] dotenvy::Error),
|
||||
#[error("Invalid index to sort by: {0}")]
|
||||
InvalidIndex(String),
|
||||
#[async_trait]
|
||||
pub trait SearchBackend: Send + Sync {
|
||||
async fn search_for_project(
|
||||
&self,
|
||||
info: &SearchRequest,
|
||||
) -> eyre::Result<SearchResults>;
|
||||
|
||||
async fn index_projects(
|
||||
&self,
|
||||
ro_pool: PgPool,
|
||||
redis: RedisPool,
|
||||
) -> eyre::Result<()>;
|
||||
|
||||
async fn remove_documents(&self, ids: &[VersionId]) -> eyre::Result<()>;
|
||||
|
||||
async fn tasks(&self) -> eyre::Result<Value>;
|
||||
|
||||
async fn tasks_cancel(
|
||||
&self,
|
||||
filter: &TasksCancelFilter,
|
||||
) -> eyre::Result<()>;
|
||||
}
|
||||
|
||||
impl actix_web::ResponseError for SearchError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
SearchError::Env(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
SearchError::MeiliSearch(..) => StatusCode::BAD_REQUEST,
|
||||
SearchError::Serde(..) => StatusCode::BAD_REQUEST,
|
||||
SearchError::IntParsing(..) => StatusCode::BAD_REQUEST,
|
||||
SearchError::InvalidIndex(..) => StatusCode::BAD_REQUEST,
|
||||
SearchError::FormatError(..) => StatusCode::BAD_REQUEST,
|
||||
}
|
||||
}
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum TasksCancelFilter {
|
||||
All,
|
||||
AllEnqueued,
|
||||
Indexes { indexes: Vec<String> },
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
HttpResponse::build(self.status_code()).json(ApiError {
|
||||
error: match self {
|
||||
SearchError::Env(..) => "environment_error",
|
||||
SearchError::MeiliSearch(..) => "meilisearch_error",
|
||||
SearchError::Serde(..) => "invalid_input",
|
||||
SearchError::IntParsing(..) => "invalid_input",
|
||||
SearchError::InvalidIndex(..) => "invalid_input",
|
||||
SearchError::FormatError(..) => "invalid_input",
|
||||
},
|
||||
description: self.to_string(),
|
||||
details: None,
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum SearchBackendKind {
|
||||
Meilisearch,
|
||||
Elasticsearch,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error("invalid search backend kind")]
|
||||
pub struct InvalidSearchBackendKind;
|
||||
|
||||
impl FromStr for SearchBackendKind {
|
||||
type Err = InvalidSearchBackendKind;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(match s {
|
||||
"meilisearch" => SearchBackendKind::Meilisearch,
|
||||
"elasticsearch" => SearchBackendKind::Elasticsearch,
|
||||
_ => return Err(InvalidSearchBackendKind),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MeilisearchReadClient {
|
||||
pub client: Client,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for MeilisearchReadClient {
|
||||
type Target = Client;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.client
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BatchClient {
|
||||
pub clients: Vec<Client>,
|
||||
}
|
||||
|
||||
impl BatchClient {
|
||||
pub fn new(clients: Vec<Client>) -> Self {
|
||||
Self { clients }
|
||||
}
|
||||
|
||||
pub async fn with_all_clients<'a, T, G, Fut>(
|
||||
&'a self,
|
||||
task_name: &str,
|
||||
generator: G,
|
||||
) -> Result<Vec<T>, IndexingError>
|
||||
where
|
||||
G: Fn(&'a Client) -> Fut,
|
||||
Fut: Future<Output = Result<T, IndexingError>> + 'a,
|
||||
{
|
||||
let mut tasks = FuturesOrdered::new();
|
||||
for (idx, client) in self.clients.iter().enumerate() {
|
||||
tasks.push_back(generator(client).instrument(info_span!(
|
||||
"client_task",
|
||||
task.name = task_name,
|
||||
client.idx = idx,
|
||||
)));
|
||||
}
|
||||
|
||||
let results = tasks.try_collect::<Vec<T>>().await?;
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
pub fn across_all<T, F, R>(&self, data: Vec<T>, mut predicate: F) -> Vec<R>
|
||||
where
|
||||
F: FnMut(T, &Client) -> R,
|
||||
{
|
||||
assert_eq!(
|
||||
data.len(),
|
||||
self.clients.len(),
|
||||
"mismatch between data len and meilisearch client count"
|
||||
);
|
||||
self.clients
|
||||
.iter()
|
||||
.zip(data)
|
||||
.map(|(client, item)| predicate(item, client))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchConfig {
|
||||
pub addresses: Vec<String>,
|
||||
pub read_lb_address: String,
|
||||
pub key: String,
|
||||
pub meta_namespace: String,
|
||||
}
|
||||
|
||||
impl SearchConfig {
|
||||
// Panics if the environment variables are not set,
|
||||
// but these are already checked for on startup.
|
||||
pub fn new(meta_namespace: Option<String>) -> Self {
|
||||
Self {
|
||||
addresses: ENV.MEILISEARCH_WRITE_ADDRS.0.clone(),
|
||||
key: ENV.MEILISEARCH_KEY.clone(),
|
||||
meta_namespace: meta_namespace.unwrap_or_default(),
|
||||
read_lb_address: ENV.MEILISEARCH_READ_ADDR.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn make_loadbalanced_read_client(
|
||||
&self,
|
||||
) -> Result<MeilisearchReadClient, meilisearch_sdk::errors::Error> {
|
||||
Ok(MeilisearchReadClient {
|
||||
client: Client::new(&self.read_lb_address, Some(&self.key))?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn make_batch_client(
|
||||
&self,
|
||||
) -> Result<BatchClient, meilisearch_sdk::errors::Error> {
|
||||
Ok(BatchClient::new(
|
||||
self.addresses
|
||||
.iter()
|
||||
.map(|address| {
|
||||
Client::new(address.as_str(), Some(self.key.as_str()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
))
|
||||
}
|
||||
|
||||
// Next: true if we want the next index (we are preparing the next swap), false if we want the current index (searching)
|
||||
pub fn get_index_name(&self, index: &str, next: bool) -> String {
|
||||
let alt = if next { "_alt" } else { "" };
|
||||
format!("{}_{}_{}", self.meta_namespace, index, alt)
|
||||
}
|
||||
}
|
||||
|
||||
/// A project document used for uploading projects to MeiliSearch's indices.
|
||||
/// This contains some extra data that is not returned by search results.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct UploadSearchProject {
|
||||
pub version_id: String,
|
||||
@@ -249,147 +142,14 @@ pub struct ResultSearchProject {
|
||||
pub loader_fields: HashMap<String, Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
pub fn get_sort_index(
|
||||
config: &SearchConfig,
|
||||
index: &str,
|
||||
) -> Result<(String, [&'static str; 1]), SearchError> {
|
||||
let projects_name = config.get_index_name("projects", false);
|
||||
let projects_filtered_name =
|
||||
config.get_index_name("projects_filtered", false);
|
||||
Ok(match index {
|
||||
"relevance" => (projects_name, ["downloads:desc"]),
|
||||
"downloads" => (projects_filtered_name, ["downloads:desc"]),
|
||||
"follows" => (projects_name, ["follows:desc"]),
|
||||
"updated" => (projects_name, ["date_modified:desc"]),
|
||||
"newest" => (projects_name, ["date_created:desc"]),
|
||||
i => return Err(SearchError::InvalidIndex(i.to_string())),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn search_for_project(
|
||||
info: &SearchRequest,
|
||||
config: &SearchConfig,
|
||||
) -> Result<SearchResults, SearchError> {
|
||||
let offset: usize = info.offset.as_deref().unwrap_or("0").parse()?;
|
||||
let index = info.index.as_deref().unwrap_or("relevance");
|
||||
let limit = info
|
||||
.limit
|
||||
.as_deref()
|
||||
.unwrap_or("10")
|
||||
.parse::<usize>()?
|
||||
.min(100);
|
||||
|
||||
let sort = get_sort_index(config, index)?;
|
||||
let client = config.make_loadbalanced_read_client()?;
|
||||
let meilisearch_index = client.get_index(sort.0).await?;
|
||||
|
||||
let mut filter_string = String::new();
|
||||
|
||||
// Convert offset and limit to page and hits_per_page
|
||||
let hits_per_page = if limit == 0 { 1 } else { limit };
|
||||
|
||||
let page = offset / hits_per_page + 1;
|
||||
|
||||
let results = {
|
||||
let mut query = meilisearch_index.search();
|
||||
query
|
||||
.with_page(page)
|
||||
.with_hits_per_page(hits_per_page)
|
||||
.with_query(info.query.as_deref().unwrap_or_default())
|
||||
.with_sort(&sort.1);
|
||||
|
||||
if let Some(new_filters) = info.new_filters.as_deref() {
|
||||
query.with_filter(new_filters);
|
||||
} else {
|
||||
let facets = if let Some(facets) = &info.facets {
|
||||
Some(serde_json::from_str::<Vec<Vec<Value>>>(facets)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let filters: Cow<_> =
|
||||
match (info.filters.as_deref(), info.version.as_deref()) {
|
||||
(Some(f), Some(v)) => format!("({f}) AND ({v})").into(),
|
||||
(Some(f), None) => f.into(),
|
||||
(None, Some(v)) => v.into(),
|
||||
(None, None) => "".into(),
|
||||
};
|
||||
|
||||
if let Some(facets) = facets {
|
||||
// Search can now *optionally* have a third inner array: So Vec(AND)<Vec(OR)<Vec(AND)< _ >>>
|
||||
// For every inner facet, we will check if it can be deserialized into a Vec<&str>, and do so.
|
||||
// If not, we will assume it is a single facet and wrap it in a Vec.
|
||||
let facets: Vec<Vec<Vec<String>>> = facets
|
||||
.into_iter()
|
||||
.map(|facets| {
|
||||
facets
|
||||
.into_iter()
|
||||
.map(|facet| {
|
||||
if facet.is_array() {
|
||||
serde_json::from_value::<Vec<String>>(facet)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
vec![
|
||||
serde_json::from_value::<String>(facet)
|
||||
.unwrap_or_default(),
|
||||
]
|
||||
}
|
||||
})
|
||||
.collect_vec()
|
||||
})
|
||||
.collect_vec();
|
||||
|
||||
filter_string.push('(');
|
||||
for (index, facet_outer_list) in facets.iter().enumerate() {
|
||||
filter_string.push('(');
|
||||
|
||||
for (facet_outer_index, facet_inner_list) in
|
||||
facet_outer_list.iter().enumerate()
|
||||
{
|
||||
filter_string.push('(');
|
||||
for (facet_inner_index, facet) in
|
||||
facet_inner_list.iter().enumerate()
|
||||
{
|
||||
filter_string.push_str(&facet.replace(':', " = "));
|
||||
if facet_inner_index != (facet_inner_list.len() - 1)
|
||||
{
|
||||
filter_string.push_str(" AND ")
|
||||
}
|
||||
}
|
||||
filter_string.push(')');
|
||||
|
||||
if facet_outer_index != (facet_outer_list.len() - 1) {
|
||||
filter_string.push_str(" OR ")
|
||||
}
|
||||
}
|
||||
|
||||
filter_string.push(')');
|
||||
|
||||
if index != (facets.len() - 1) {
|
||||
filter_string.push_str(" AND ")
|
||||
}
|
||||
}
|
||||
filter_string.push(')');
|
||||
|
||||
if !filters.is_empty() {
|
||||
write!(filter_string, " AND ({filters})")?;
|
||||
}
|
||||
} else {
|
||||
filter_string.push_str(&filters);
|
||||
}
|
||||
|
||||
if !filter_string.is_empty() {
|
||||
query.with_filter(&filter_string);
|
||||
}
|
||||
pub fn backend(meta_namespace: Option<String>) -> Box<dyn SearchBackend> {
|
||||
match ENV.SEARCH_BACKEND {
|
||||
SearchBackendKind::Meilisearch => {
|
||||
let config = backend::MeilisearchConfig::new(meta_namespace);
|
||||
Box::new(backend::Meilisearch::new(config))
|
||||
}
|
||||
|
||||
query.execute::<ResultSearchProject>().await?
|
||||
};
|
||||
|
||||
Ok(SearchResults {
|
||||
hits: results.hits.into_iter().map(|r| r.result).collect(),
|
||||
page: results.page.unwrap_or_default(),
|
||||
hits_per_page: results.hits_per_page.unwrap_or_default(),
|
||||
total_hits: results.total_hits.unwrap_or_default(),
|
||||
})
|
||||
SearchBackendKind::Elasticsearch => {
|
||||
Box::new(backend::Elasticsearch::new(meta_namespace).unwrap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ use crate::database::PgPool;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::database::{MIGRATOR, ReadOnlyPgPool};
|
||||
use crate::env::ENV;
|
||||
use crate::search;
|
||||
use crate::search::{self, SearchBackend};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
@@ -42,8 +43,8 @@ pub struct TemporaryDatabase {
|
||||
pub pool: PgPool,
|
||||
pub ro_pool: ReadOnlyPgPool,
|
||||
pub redis_pool: RedisPool,
|
||||
pub search_config: crate::search::SearchConfig,
|
||||
pub database_name: String,
|
||||
pub search_backend: Arc<dyn SearchBackend>,
|
||||
}
|
||||
|
||||
impl TemporaryDatabase {
|
||||
@@ -91,15 +92,14 @@ impl TemporaryDatabase {
|
||||
// Gets new Redis pool
|
||||
let redis_pool = RedisPool::new(temp_database_name.clone());
|
||||
|
||||
// Create new meilisearch config
|
||||
let search_config =
|
||||
search::SearchConfig::new(Some(temp_database_name.clone()));
|
||||
// Create search backend
|
||||
let search_backend = search::backend(Some(temp_database_name.clone()));
|
||||
Self {
|
||||
pool,
|
||||
ro_pool,
|
||||
database_name: temp_database_name,
|
||||
redis_pool,
|
||||
search_config,
|
||||
search_backend: Arc::from(search_backend),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +193,9 @@ impl TemporaryDatabase {
|
||||
ro_pool: ReadOnlyPgPool::from(pool.clone()),
|
||||
database_name: TEMPLATE_DATABASE_NAME.to_string(),
|
||||
redis_pool: RedisPool::new(name.clone()),
|
||||
search_config: search::SearchConfig::new(Some(name)),
|
||||
search_backend: Arc::from(search::backend(Some(
|
||||
name.clone(),
|
||||
))),
|
||||
};
|
||||
let setup_api =
|
||||
TestEnvironment::<ApiV3>::build_setup_api(&db).await;
|
||||
|
||||
@@ -23,7 +23,6 @@ pub mod search;
|
||||
// If making a test, you should probably use environment::TestEnvironment::build() (which calls this)
|
||||
pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
|
||||
println!("Setting up labrinth config");
|
||||
dotenvy::dotenv().ok();
|
||||
env::init().expect("failed to initialize environment variables");
|
||||
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
@@ -31,7 +30,7 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
|
||||
let pool = db.pool.clone();
|
||||
let ro_pool = db.ro_pool.clone();
|
||||
let redis_pool = db.redis_pool.clone();
|
||||
let search_config = db.search_config.clone();
|
||||
let search_backend = db.search_backend.clone();
|
||||
let file_host: Arc<dyn file_hosting::FileHost + Send + Sync> =
|
||||
Arc::new(file_hosting::MockHost::new());
|
||||
let mut clickhouse = clickhouse::init_client().await.unwrap();
|
||||
@@ -48,7 +47,7 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
|
||||
pool.clone(),
|
||||
ro_pool.clone(),
|
||||
redis_pool.clone(),
|
||||
search_config,
|
||||
search_backend.into(),
|
||||
&mut clickhouse,
|
||||
file_host.clone(),
|
||||
stripe_client,
|
||||
|
||||
+173
-8
@@ -37,6 +37,167 @@ services:
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
elasticsearch-certs:
|
||||
image: elasticsearch:9.3.0
|
||||
container_name: labrinth-elasticsearch-certs
|
||||
user: '0'
|
||||
networks:
|
||||
- elasticsearch-mesh
|
||||
restart: 'no'
|
||||
volumes:
|
||||
- elasticsearch-certs:/usr/share/elasticsearch/config/certs
|
||||
command: |
|
||||
bash -c '
|
||||
set -euo pipefail
|
||||
if [ ! -s config/certs/ca/ca.crt ] || [ ! -s config/certs/elasticsearch0/elasticsearch0.crt ] || [ ! -s config/certs/elasticsearch1/elasticsearch1.crt ] || [ ! -s config/certs/elasticsearch2/elasticsearch2.crt ]; then
|
||||
rm -rf config/certs/*
|
||||
printf "%s\n" \
|
||||
"instances:" \
|
||||
" - name: elasticsearch0" \
|
||||
" dns:" \
|
||||
" - elasticsearch0" \
|
||||
" - localhost" \
|
||||
" ip:" \
|
||||
" - 127.0.0.1" \
|
||||
" - name: elasticsearch1" \
|
||||
" dns:" \
|
||||
" - elasticsearch1" \
|
||||
" - localhost" \
|
||||
" ip:" \
|
||||
" - 127.0.0.1" \
|
||||
" - name: elasticsearch2" \
|
||||
" dns:" \
|
||||
" - elasticsearch2" \
|
||||
" - localhost" \
|
||||
" ip:" \
|
||||
" - 127.0.0.1" \
|
||||
> config/certs/instances.yml
|
||||
bin/elasticsearch-certutil ca --silent --pem --out config/certs/ca.zip
|
||||
unzip config/certs/ca.zip -d config/certs
|
||||
bin/elasticsearch-certutil cert --silent --pem --in config/certs/instances.yml --ca-cert config/certs/ca/ca.crt --ca-key config/certs/ca/ca.key --out config/certs/certs.zip
|
||||
unzip config/certs/certs.zip -d config/certs
|
||||
fi
|
||||
chown -R 1000:0 config/certs
|
||||
find config/certs -type d -exec chmod 750 {} \;
|
||||
find config/certs -type f -exec chmod 640 {} \;
|
||||
echo "Set up certificates"
|
||||
'
|
||||
elasticsearch0:
|
||||
image: elasticsearch:9.3.0
|
||||
container_name: labrinth-elasticsearch0
|
||||
networks:
|
||||
- elasticsearch-mesh
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
elasticsearch-certs:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
- '127.0.0.1:9200:9200'
|
||||
volumes:
|
||||
- elasticsearch0-data:/usr/share/elasticsearch/data
|
||||
- elasticsearch-certs:/usr/share/elasticsearch/config/certs:ro
|
||||
environment:
|
||||
- logger.level=WARN
|
||||
- node.name=elasticsearch0
|
||||
- cluster.name=labrinth
|
||||
- cluster.initial_master_nodes=elasticsearch0,elasticsearch1,elasticsearch2
|
||||
- discovery.seed_hosts=elasticsearch1,elasticsearch2
|
||||
- bootstrap.memory_lock=false
|
||||
# auth
|
||||
- xpack.security.enabled=true
|
||||
- xpack.security.transport.ssl.enabled=true
|
||||
- xpack.security.transport.ssl.verification_mode=certificate
|
||||
- xpack.security.transport.ssl.key=certs/elasticsearch0/elasticsearch0.key
|
||||
- xpack.security.transport.ssl.certificate=certs/elasticsearch0/elasticsearch0.crt
|
||||
- xpack.security.transport.ssl.certificate_authorities=certs/ca/ca.crt
|
||||
- ELASTIC_USERNAME=elastic
|
||||
- ELASTIC_PASSWORD=elastic
|
||||
mem_limit: 1g
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD-SHELL',
|
||||
'curl -s -u elastic:elastic http://localhost:9200/_cluster/health | grep -qE "\"status\":\"(yellow|green)\""',
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
elasticsearch1:
|
||||
image: elasticsearch:9.3.0
|
||||
container_name: labrinth-elasticsearch1
|
||||
networks:
|
||||
- elasticsearch-mesh
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
elasticsearch-certs:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
- elasticsearch1-data:/usr/share/elasticsearch/data
|
||||
- elasticsearch-certs:/usr/share/elasticsearch/config/certs:ro
|
||||
environment:
|
||||
- logger.level=WARN
|
||||
- node.name=elasticsearch1
|
||||
- cluster.name=labrinth
|
||||
- cluster.initial_master_nodes=elasticsearch0,elasticsearch1,elasticsearch2
|
||||
- discovery.seed_hosts=elasticsearch0,elasticsearch2
|
||||
- bootstrap.memory_lock=false
|
||||
# auth
|
||||
- xpack.security.enabled=true
|
||||
- xpack.security.transport.ssl.enabled=true
|
||||
- xpack.security.transport.ssl.verification_mode=certificate
|
||||
- xpack.security.transport.ssl.key=certs/elasticsearch1/elasticsearch1.key
|
||||
- xpack.security.transport.ssl.certificate=certs/elasticsearch1/elasticsearch1.crt
|
||||
- xpack.security.transport.ssl.certificate_authorities=certs/ca/ca.crt
|
||||
- ELASTIC_USERNAME=elastic
|
||||
- ELASTIC_PASSWORD=elastic
|
||||
mem_limit: 1g
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD-SHELL',
|
||||
'curl -s -u elastic:elastic http://localhost:9200/_cluster/health | grep -qE "\"status\":\"(yellow|green)\""',
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
elasticsearch2:
|
||||
image: elasticsearch:9.3.0
|
||||
container_name: labrinth-elasticsearch2
|
||||
networks:
|
||||
- elasticsearch-mesh
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
elasticsearch-certs:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
- elasticsearch2-data:/usr/share/elasticsearch/data
|
||||
- elasticsearch-certs:/usr/share/elasticsearch/config/certs:ro
|
||||
environment:
|
||||
- logger.level=WARN
|
||||
- node.name=elasticsearch2
|
||||
- cluster.name=labrinth
|
||||
- cluster.initial_master_nodes=elasticsearch0,elasticsearch1,elasticsearch2
|
||||
- discovery.seed_hosts=elasticsearch0,elasticsearch1
|
||||
- bootstrap.memory_lock=false
|
||||
# auth
|
||||
- xpack.security.enabled=true
|
||||
- xpack.security.transport.ssl.enabled=true
|
||||
- xpack.security.transport.ssl.verification_mode=certificate
|
||||
- xpack.security.transport.ssl.key=certs/elasticsearch2/elasticsearch2.key
|
||||
- xpack.security.transport.ssl.certificate=certs/elasticsearch2/elasticsearch2.crt
|
||||
- xpack.security.transport.ssl.certificate_authorities=certs/ca/ca.crt
|
||||
- ELASTIC_USERNAME=elastic
|
||||
- ELASTIC_PASSWORD=elastic
|
||||
mem_limit: 1g
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD-SHELL',
|
||||
'curl -s -u elastic:elastic http://localhost:9200/_cluster/health | grep -qE "\"status\":\"(yellow|green)\""',
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: labrinth-redis
|
||||
@@ -89,8 +250,6 @@ services:
|
||||
ports:
|
||||
- '127.0.0.1:13000:3000'
|
||||
extra_hosts:
|
||||
# Gotenberg must send a message on a webhook to our backend,
|
||||
# so it must have access to our local network
|
||||
- 'host.docker.internal:host-gateway'
|
||||
labrinth:
|
||||
profiles:
|
||||
@@ -109,6 +268,12 @@ services:
|
||||
condition: service_healthy
|
||||
meilisearch:
|
||||
condition: service_healthy
|
||||
elasticsearch0:
|
||||
condition: service_healthy
|
||||
elasticsearch1:
|
||||
condition: service_healthy
|
||||
elasticsearch2:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
clickhouse:
|
||||
@@ -136,14 +301,9 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
volumes:
|
||||
# Labrinth deposits version files here;
|
||||
# Delphi reads them from here
|
||||
- /tmp/modrinth:/tmp/modrinth:ro,z
|
||||
extra_hosts:
|
||||
# Delphi must send a message on a webhook to our backend,
|
||||
# so it must have access to our local network
|
||||
- 'host.docker.internal:host-gateway'
|
||||
|
||||
# Sharded Meilisearch
|
||||
meilisearch1:
|
||||
profiles:
|
||||
@@ -166,7 +326,6 @@ services:
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
nginx-meilisearch-lb:
|
||||
profiles:
|
||||
- sharded-meilisearch
|
||||
@@ -186,9 +345,15 @@ services:
|
||||
networks:
|
||||
meilisearch-mesh:
|
||||
driver: bridge
|
||||
elasticsearch-mesh:
|
||||
driver: bridge
|
||||
volumes:
|
||||
meilisearch-data:
|
||||
meilisearch1-data:
|
||||
elasticsearch0-data:
|
||||
elasticsearch1-data:
|
||||
elasticsearch2-data:
|
||||
elasticsearch-certs:
|
||||
db-data:
|
||||
redis-data:
|
||||
labrinth-cdn-data:
|
||||
|
||||
Reference in New Issue
Block a user