wip: projects/versions collections

This commit is contained in:
aecsocket
2026-07-15 13:49:33 +01:00
parent 61990b0599
commit 77deb033f7
15 changed files with 1446 additions and 465 deletions
+4 -16
View File
@@ -1,9 +1,9 @@
use crate::database::DBProject; use crate::database::DBProject;
use crate::database::models::{DBProjectId, DBVersionId}; use crate::database::models::DBProjectId;
use crate::database::redis::RedisPool; use crate::database::redis::RedisPool;
use crate::env::ENV; use crate::env::ENV;
use crate::models::exp; use crate::models::exp;
use crate::models::ids::{ProjectId, VersionId}; use crate::models::ids::ProjectId;
use crate::models::projects::ProjectStatus; use crate::models::projects::ProjectStatus;
use crate::search::incremental::IncrementalSearchQueue; use crate::search::incremental::IncrementalSearchQueue;
use crate::{database::PgPool, util::error::Context}; use crate::{database::PgPool, util::error::Context};
@@ -175,26 +175,14 @@ impl ServerPingQueue {
} }
if updated_project { if updated_project {
let version_ids = sqlx::query_scalar!(
"SELECT id FROM versions WHERE mod_id = $1",
DBProjectId::from(*project_id) as DBProjectId,
)
.fetch_all(&self.db)
.await
.wrap_err("failed to fetch project version IDs")?
.into_iter()
.map(|version_id| VersionId::from(DBVersionId(version_id)))
.collect::<Vec<_>>();
let clear_cache = DBProject::clear_cache( let clear_cache = DBProject::clear_cache(
(*project_id).into(), (*project_id).into(),
None, None,
None, None,
&self.redis, &self.redis,
); );
let queue_search = self let queue_search =
.incremental_search_queue self.incremental_search_queue.push(*project_id);
.push(*project_id, version_ids);
let (clear_cache_result, _) = let (clear_cache_result, _) =
join(clear_cache, queue_search).await; join(clear_cache, queue_search).await;
@@ -1161,7 +1161,6 @@ pub async fn submit_report(
if verdict == DelphiVerdict::Unsafe { if verdict == DelphiVerdict::Unsafe {
crate::routes::v3::projects::clear_project_cache_and_queue_search( crate::routes::v3::projects::clear_project_cache_and_queue_search(
&pool,
&redis, &redis,
&search_state, &search_state,
project_id, project_id,
@@ -810,7 +810,6 @@ pub async fn organization_delete(
for project_id in organization_project_ids { for project_id in organization_project_ids {
super::projects::clear_project_cache_and_queue_search( super::projects::clear_project_cache_and_queue_search(
&pool,
&redis, &redis,
&search_state, &search_state,
project_id, project_id,
@@ -980,7 +979,6 @@ pub async fn organization_projects_add(
) )
.await?; .await?;
super::projects::clear_project_cache_and_queue_search( super::projects::clear_project_cache_and_queue_search(
&pool,
&redis, &redis,
&search_state, &search_state,
project_item.inner.id, project_item.inner.id,
@@ -1173,7 +1171,6 @@ pub async fn organization_projects_remove(
) )
.await?; .await?;
super::projects::clear_project_cache_and_queue_search( super::projects::clear_project_cache_and_queue_search(
&pool,
&redis, &redis,
&search_state, &search_state,
project_item.inner.id, project_item.inner.id,
@@ -358,7 +358,6 @@ pub async fn project_create_internal(
} else { } else {
transaction.commit().await?; transaction.commit().await?;
super::projects::clear_project_cache_and_queue_search( super::projects::clear_project_cache_and_queue_search(
&client,
&redis, &redis,
&search_state, &search_state,
project_id.into(), project_id.into(),
@@ -425,7 +424,6 @@ pub async fn project_create_with_id(
} else { } else {
transaction.commit().await?; transaction.commit().await?;
super::projects::clear_project_cache_and_queue_search( super::projects::clear_project_cache_and_queue_search(
&client,
&redis, &redis,
&search_state, &search_state,
project_id.into(), project_id.into(),
@@ -345,7 +345,6 @@ pub async fn create(
.wrap_internal_err("failed to commit transaction")?; .wrap_internal_err("failed to commit transaction")?;
super::super::projects::clear_project_cache_and_queue_search( super::super::projects::clear_project_cache_and_queue_search(
&db,
&redis, &redis,
&search_state, &search_state,
project_id.into(), project_id.into(),
+52 -30
View File
@@ -72,35 +72,42 @@ pub fn project_config(cfg: &mut actix_web::web::ServiceConfig) {
} }
pub async fn clear_project_cache_and_queue_search( pub async fn clear_project_cache_and_queue_search(
pool: &PgPool,
redis: &RedisPool, redis: &RedisPool,
search_state: &SearchState, search_state: &SearchState,
project_id: db_ids::DBProjectId, project_id: db_ids::DBProjectId,
slug: Option<String>, slug: Option<String>,
clear_dependencies: Option<bool>, clear_dependencies: Option<bool>,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
let version_ids = sqlx::query_scalar!( clear_project_cache_and_queue_search_inner(
"SELECT id FROM versions WHERE mod_id = $1",
project_id as db_ids::DBProjectId,
)
.fetch_all(pool)
.await
.wrap_internal_err("failed to fetch project version IDs")?
.into_iter()
.map(|version_id| VersionId::from(db_ids::DBVersionId(version_id)))
.collect::<Vec<_>>();
clear_project_cache_and_queue_search_versions(
redis, redis,
search_state, search_state,
project_id, project_id,
slug, slug,
clear_dependencies, clear_dependencies,
version_ids,
) )
.await .await
} }
pub async fn clear_project_cache_and_queue_search_inner(
redis: &RedisPool,
search_state: &SearchState,
project_id: db_ids::DBProjectId,
slug: Option<String>,
clear_dependencies: Option<bool>,
) -> Result<(), ApiError> {
db_models::DBProject::clear_cache(
project_id,
slug,
clear_dependencies,
redis,
)
.await?;
search_state.queue.push(project_id.into()).await;
Ok(())
}
pub async fn clear_project_cache_and_queue_search_versions( pub async fn clear_project_cache_and_queue_search_versions(
redis: &RedisPool, redis: &RedisPool,
search_state: &SearchState, search_state: &SearchState,
@@ -119,7 +126,7 @@ pub async fn clear_project_cache_and_queue_search_versions(
search_state search_state
.queue .queue
.push(project_id.into(), version_ids) .push_versions(project_id.into(), version_ids)
.await; .await;
Ok(()) Ok(())
@@ -1133,6 +1140,9 @@ pub async fn project_edit_internal(
Ok(()) Ok(())
} }
let reindex_version_project_types =
new_project.minecraft_java_server.is_some();
update( update(
&mut transaction, &mut transaction,
id, id,
@@ -1202,15 +1212,26 @@ pub async fn project_edit_internal(
transaction.commit().await?; transaction.commit().await?;
clear_project_cache_and_queue_search( if reindex_version_project_types {
&pool, clear_project_cache_and_queue_search_versions(
&redis, &redis,
&search_state, &search_state,
project_item.inner.id, project_item.inner.id,
project_item.inner.slug, project_item.inner.slug,
None, None,
) project_item.versions.iter().copied().map(VersionId::from),
.await?; )
.await?;
} else {
clear_project_cache_and_queue_search(
&redis,
&search_state,
project_item.inner.id,
project_item.inner.slug,
None,
)
.await?;
}
// Remove no longer searchable projects from search index // Remove no longer searchable projects from search index
if let (true, Some(false)) = ( if let (true, Some(false)) = (
@@ -1764,7 +1785,6 @@ pub async fn projects_edit(
for (project_id, slug) in changed_projects { for (project_id, slug) in changed_projects {
clear_project_cache_and_queue_search( clear_project_cache_and_queue_search(
&pool,
&redis, &redis,
&search_state, &search_state,
project_id, project_id,
@@ -1996,7 +2016,6 @@ pub async fn project_icon_edit_internal(
transaction.commit().await?; transaction.commit().await?;
clear_project_cache_and_queue_search( clear_project_cache_and_queue_search(
&pool,
&redis, &redis,
&search_state, &search_state,
project_item.inner.id, project_item.inner.id,
@@ -2116,7 +2135,6 @@ pub async fn delete_project_icon_internal(
transaction.commit().await?; transaction.commit().await?;
clear_project_cache_and_queue_search( clear_project_cache_and_queue_search(
&pool,
&redis, &redis,
&search_state, &search_state,
project_item.inner.id, project_item.inner.id,
@@ -2317,7 +2335,6 @@ pub async fn add_gallery_item_internal(
transaction.commit().await?; transaction.commit().await?;
clear_project_cache_and_queue_search( clear_project_cache_and_queue_search(
&pool,
&redis, &redis,
&search_state, &search_state,
project_item.inner.id, project_item.inner.id,
@@ -2536,7 +2553,6 @@ pub async fn edit_gallery_item_internal(
transaction.commit().await?; transaction.commit().await?;
clear_project_cache_and_queue_search( clear_project_cache_and_queue_search(
&pool,
&redis, &redis,
&search_state, &search_state,
project_item.inner.id, project_item.inner.id,
@@ -2685,7 +2701,6 @@ pub async fn delete_gallery_item_internal(
transaction.commit().await?; transaction.commit().await?;
clear_project_cache_and_queue_search( clear_project_cache_and_queue_search(
&pool,
&redis, &redis,
&search_state, &search_state,
project_item.inner.id, project_item.inner.id,
@@ -2834,6 +2849,13 @@ pub async fn project_delete_internal(
&redis, &redis,
) )
.await?; .await?;
search_state
.backend
.remove_project_version_documents(&[project.inner.id.into()])
.await
.wrap_internal_err(
"failed to remove project versions from search index",
)?;
search_state search_state
.backend .backend
.remove_project_documents(&[project.inner.id.into()]) .remove_project_documents(&[project.inner.id.into()])
+11 -2
View File
@@ -27,6 +27,7 @@ use crate::models::teams::ProjectPermissions;
use crate::queue::file_scan::get_files_missing_attribution; use crate::queue::file_scan::get_files_missing_attribution;
use crate::queue::session::AuthQueue; use crate::queue::session::AuthQueue;
use crate::routes::internal::delphi; use crate::routes::internal::delphi;
use crate::search::incremental::consume::reindex_project;
use crate::search::{SearchBackend, SearchState}; use crate::search::{SearchBackend, SearchState};
use crate::util::error::Context; use crate::util::error::Context;
use crate::util::img; use crate::util::img;
@@ -1257,9 +1258,17 @@ pub async fn version_delete(
) )
.await?; .await?;
search_backend search_backend
.remove_documents(&[version.inner.id.into()]) .remove_version_documents(&[version.inner.id.into()])
.await .await
.wrap_internal_err("failed to remove documents")?; .wrap_internal_err("failed to remove version search document")?;
reindex_project(
&pool,
&redis,
search_backend.as_ref(),
version.inner.project_id.into(),
)
.await
.wrap_internal_err("failed to reindex project")?;
if result.is_some() { if result.is_some() {
Ok(HttpResponse::NoContent().body("")) Ok(HttpResponse::NoContent().body(""))
} else { } else {
@@ -50,14 +50,7 @@ pub enum SearchIndex {
MinecraftJavaServerPlayersOnline, MinecraftJavaServerPlayersOnline,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchIndexName {
Projects,
ProjectsFiltered,
}
pub struct SearchSort { pub struct SearchSort {
pub index_name: SearchIndexName,
pub index: SearchIndex, pub index: SearchIndex,
} }
@@ -65,16 +58,12 @@ pub fn parse_search_index(
index: &str, index: &str,
new_filters: Option<&str>, new_filters: Option<&str>,
) -> Result<SearchSort, ApiError> { ) -> Result<SearchSort, ApiError> {
let projects_name = SearchIndexName::Projects;
let projects_filtered_name = SearchIndexName::ProjectsFiltered;
// TODO: this is a dumb hack, the frontend should pass the project type it's filtering directly // TODO: this is a dumb hack, the frontend should pass the project type it's filtering directly
let is_server = new_filters let is_server = new_filters
.is_some_and(|f| f.contains("project_types = minecraft_java_server")); .is_some_and(|f| f.contains("project_types = minecraft_java_server"));
Ok(match index { Ok(match index {
"relevance" => SearchSort { "relevance" => SearchSort {
index_name: projects_name,
index: if is_server { index: if is_server {
SearchIndex::MinecraftJavaServerVerifiedPlays2w SearchIndex::MinecraftJavaServerVerifiedPlays2w
} else { } else {
@@ -82,27 +71,21 @@ pub fn parse_search_index(
}, },
}, },
"downloads" => SearchSort { "downloads" => SearchSort {
index_name: projects_filtered_name,
index: SearchIndex::Downloads, index: SearchIndex::Downloads,
}, },
"follows" => SearchSort { "follows" => SearchSort {
index_name: projects_name,
index: SearchIndex::Follows, index: SearchIndex::Follows,
}, },
"updated" | "date_modified" => SearchSort { "updated" | "date_modified" => SearchSort {
index_name: projects_name,
index: SearchIndex::Updated, index: SearchIndex::Updated,
}, },
"newest" | "date_created" => SearchSort { "newest" | "date_created" => SearchSort {
index_name: projects_name,
index: SearchIndex::Newest, index: SearchIndex::Newest,
}, },
"minecraft_java_server.verified_plays_2w" => SearchSort { "minecraft_java_server.verified_plays_2w" => SearchSort {
index_name: projects_name,
index: SearchIndex::MinecraftJavaServerVerifiedPlays2w, index: SearchIndex::MinecraftJavaServerVerifiedPlays2w,
}, },
"minecraft_java_server.ping.data.players_online" => SearchSort { "minecraft_java_server.ping.data.players_online" => SearchSort {
index_name: projects_name,
index: SearchIndex::MinecraftJavaServerPlayersOnline, index: SearchIndex::MinecraftJavaServerPlayersOnline,
}, },
i => return Err(ApiError::Request(eyre!("invalid index '{i}'"))), i => return Err(ApiError::Request(eyre!("invalid index '{i}'"))),
+2 -2
View File
@@ -2,7 +2,7 @@ mod common;
pub mod typesense; pub mod typesense;
pub use common::{ pub use common::{
ParsedSearchRequest, SearchIndex, SearchIndexName, SearchSort, ParsedSearchRequest, SearchIndex, SearchSort, combined_search_filters,
combined_search_filters, parse_search_index, parse_search_request, parse_search_index, parse_search_request,
}; };
pub use typesense::{Typesense, TypesenseConfig}; pub use typesense::{Typesense, TypesenseConfig};
File diff suppressed because it is too large Load Diff
+25 -21
View File
@@ -36,7 +36,11 @@ impl IncrementalSearchQueue {
} }
} }
pub async fn push( pub async fn push(&self, project_id: ProjectId) {
self.operations.lock().await.push_project_change(project_id);
}
pub async fn push_versions(
&self, &self,
project_id: ProjectId, project_id: ProjectId,
version_ids: impl IntoIterator<Item = VersionId>, version_ids: impl IntoIterator<Item = VersionId>,
@@ -44,7 +48,7 @@ impl IncrementalSearchQueue {
self.operations self.operations
.lock() .lock()
.await .await
.push_project_change(project_id, version_ids); .push_version_change(project_id, version_ids);
} }
pub async fn push_project_removal(&self, project_id: ProjectId) { pub async fn push_project_removal(&self, project_id: ProjectId) {
@@ -123,22 +127,27 @@ impl PendingSearchIndexOperations {
&& self.removed_project_ids.is_empty() && self.removed_project_ids.is_empty()
} }
fn push_project_change( fn push_project_change(&mut self, project_id: ProjectId) {
if !self.removed_project_ids.contains(&project_id) {
self.changed_project_ids.insert(project_id);
}
}
fn push_version_change(
&mut self, &mut self,
project_id: ProjectId, project_id: ProjectId,
version_ids: impl IntoIterator<Item = VersionId>, version_ids: impl IntoIterator<Item = VersionId>,
) { ) {
if !self.removed_project_ids.contains(&project_id) { if self.removed_project_ids.contains(&project_id) {
let version_ids = version_ids.into_iter().collect::<HashSet<_>>(); return;
if version_ids.is_empty() { }
self.changed_project_versions.remove(&project_id);
self.changed_project_ids.insert(project_id); let version_ids = version_ids.into_iter().collect::<HashSet<_>>();
} else if !self.changed_project_ids.contains(&project_id) { if !version_ids.is_empty() {
self.changed_project_versions self.changed_project_versions
.entry(project_id) .entry(project_id)
.or_default() .or_default()
.extend(version_ids); .extend(version_ids);
}
} }
} }
@@ -151,16 +160,12 @@ impl PendingSearchIndexOperations {
fn push_event(&mut self, event: SearchProjectIndexQueueEventData) { fn push_event(&mut self, event: SearchProjectIndexQueueEventData) {
match event { match event {
SearchProjectIndexQueueEventData::Change { project_id } => { SearchProjectIndexQueueEventData::Change { project_id } => {
self.push_project_change(project_id, []) self.push_project_change(project_id)
} }
SearchProjectIndexQueueEventData::VersionChange { SearchProjectIndexQueueEventData::VersionChange {
project_id, project_id,
version_ids, version_ids,
} => { } => self.push_version_change(project_id, version_ids),
if !version_ids.is_empty() {
self.push_project_change(project_id, version_ids)
}
}
SearchProjectIndexQueueEventData::Removal { project_id } => { SearchProjectIndexQueueEventData::Removal { project_id } => {
self.push_project_removal(project_id) self.push_project_removal(project_id)
} }
@@ -188,7 +193,6 @@ impl PendingSearchIndexOperations {
} }
}, },
)); ));
events events
} }
} }
+22 -23
View File
@@ -191,7 +191,9 @@ async fn consume_batch(
.retain(|project_id| !project_ids_to_remove.contains(project_id)); .retain(|project_id| !project_ids_to_remove.contains(project_id));
project_ids_with_version_changes project_ids_with_version_changes
.retain(|project_id| !project_ids_to_remove.contains(project_id)); .retain(|project_id| !project_ids_to_remove.contains(project_id));
project_ids_to_change.retain(|project_id| {
!project_ids_with_version_changes.contains(project_id)
});
let project_ids_to_change = let project_ids_to_change =
project_ids_to_change.into_iter().collect::<Vec<_>>(); project_ids_to_change.into_iter().collect::<Vec<_>>();
let project_ids_with_version_changes = project_ids_with_version_changes let project_ids_with_version_changes = project_ids_with_version_changes
@@ -204,7 +206,7 @@ async fn consume_batch(
info!( info!(
kafka.message_count = messages_to_commit.len(), kafka.message_count = messages_to_commit.len(),
"Read all Kafka messages in {:.2?}, found {} projects to change, {} projects with version changes, {} versions to change, and {} projects to remove", "Read all Kafka messages in {:.2?}, found {} projects to change, {} projects with {} version changes, and {} projects to remove",
start.elapsed(), start.elapsed(),
project_ids_to_change.len(), project_ids_to_change.len(),
project_ids_with_version_changes.len(), project_ids_with_version_changes.len(),
@@ -219,6 +221,10 @@ async fn consume_batch(
project_count = project_ids_to_remove.len(), project_count = project_ids_to_remove.len(),
"Removing project documents" "Removing project documents"
); );
search_backend
.remove_project_version_documents(&project_ids_to_remove)
.await
.wrap_err("failed to remove project version documents")?;
search_backend search_backend
.remove_project_documents(&project_ids_to_remove) .remove_project_documents(&project_ids_to_remove)
.await .await
@@ -232,12 +238,8 @@ async fn consume_batch(
if !version_ids_to_change.is_empty() { if !version_ids_to_change.is_empty() {
let operation_start = Instant::now(); let operation_start = Instant::now();
info!(
version_count = version_ids_to_change.len(),
"Removing changed version documents",
);
search_backend search_backend
.remove_documents(&version_ids_to_change) .remove_version_documents(&version_ids_to_change)
.await .await
.wrap_err("failed to remove changed version documents")?; .wrap_err("failed to remove changed version documents")?;
info!( info!(
@@ -249,12 +251,7 @@ async fn consume_batch(
if !project_ids_with_version_changes.is_empty() { if !project_ids_with_version_changes.is_empty() {
let operation_start = Instant::now(); let operation_start = Instant::now();
info!( reindex_changed_project_versions(
project_count = project_ids_with_version_changes.len(),
version_count = version_ids_to_change.len(),
"Indexing changed project versions"
);
index_changed_project_versions(
ro_pool, ro_pool,
redis_pool, redis_pool,
search_backend, search_backend,
@@ -262,11 +259,11 @@ async fn consume_batch(
&version_ids_to_change, &version_ids_to_change,
) )
.await .await
.wrap_err("failed to index changed project version batch")?; .wrap_err("failed to reindex changed project versions")?;
info!( info!(
project_count = project_ids_with_version_changes.len(), project_count = project_ids_with_version_changes.len(),
version_count = version_ids_to_change.len(), version_count = version_ids_to_change.len(),
"Indexed changed project versions in {:.2?}", "Reindexed changed project versions in {:.2?}",
operation_start.elapsed() operation_start.elapsed()
); );
} }
@@ -275,19 +272,19 @@ async fn consume_batch(
let operation_start = Instant::now(); let operation_start = Instant::now();
info!( info!(
project_count = project_ids_to_change.len(), project_count = project_ids_to_change.len(),
"Indexing changed projects" "Reindexing changed projects"
); );
index_changed_projects( reindex_projects(
ro_pool, ro_pool,
redis_pool, redis_pool,
search_backend, search_backend,
&project_ids_to_change, &project_ids_to_change,
) )
.await .await
.wrap_err("failed to index changed project batch")?; .wrap_err("failed to reindex changed project batch")?;
info!( info!(
project_count = project_ids_to_change.len(), project_count = project_ids_to_change.len(),
"Indexed changed projects in {:.2?}", "Reindexed changed projects in {:.2?}",
operation_start.elapsed() operation_start.elapsed()
); );
} }
@@ -356,7 +353,7 @@ async fn index_changed_projects(
Ok(()) Ok(())
} }
async fn index_changed_project_versions( async fn reindex_changed_project_versions(
ro_pool: &PgPool, ro_pool: &PgPool,
redis_pool: &RedisPool, redis_pool: &RedisPool,
search_backend: &dyn SearchBackend, search_backend: &dyn SearchBackend,
@@ -383,9 +380,11 @@ async fn index_changed_project_versions(
) )
})?; })?;
info!("Fetched all project version documents, indexing into backend"); search_backend.remove_project_documents(project_ids).await?;
search_backend.index_documents(&documents.projects).await?;
search_backend.index_documents(&documents).await?; search_backend
.index_version_documents(&documents.versions)
.await?;
Ok(()) Ok(())
} }
+193 -136
View File
@@ -24,7 +24,10 @@ use crate::models::ids::{ProjectId, VersionId};
use crate::models::projects::{DependencyType, from_duplicate_version_fields}; use crate::models::projects::{DependencyType, from_duplicate_version_fields};
use crate::models::v2::projects::LegacyProject; use crate::models::v2::projects::LegacyProject;
use crate::routes::v2_reroute; use crate::routes::v2_reroute;
use crate::search::{SearchProjectDependency, UploadSearchProject}; use crate::search::{
SearchDocumentBatch, SearchProjectDependency, UploadSearchProject,
UploadSearchVersion,
};
use crate::util::error::Context; use crate::util::error::Context;
struct PartialProject { struct PartialProject {
@@ -68,7 +71,7 @@ pub async fn index_local(
redis: &RedisPool, redis: &RedisPool,
cursor: i64, cursor: i64,
limit: i64, limit: i64,
) -> eyre::Result<(Vec<UploadSearchProject>, i64)> { ) -> eyre::Result<(SearchDocumentBatch, i64)> {
info!("Indexing local projects!"); info!("Indexing local projects!");
let searchable_statuses = searchable_statuses(); let searchable_statuses = searchable_statuses();
@@ -111,12 +114,11 @@ pub async fn index_local(
let project_ids = db_projects.iter().map(|x| x.id.0).collect::<Vec<i64>>(); let project_ids = db_projects.iter().map(|x| x.id.0).collect::<Vec<i64>>();
let Some(largest) = project_ids.iter().max() else { let Some(largest) = project_ids.iter().max() else {
return Ok((vec![], i64::MAX)); return Ok((SearchDocumentBatch::default(), i64::MAX));
}; };
let uploads = let documents = build_search_documents(pool, redis, db_projects).await?;
build_search_documents(pool, redis, db_projects, None).await?; Ok((documents, *largest))
Ok((uploads, *largest))
} }
pub async fn index_project_documents( pub async fn index_project_documents(
@@ -164,7 +166,9 @@ pub async fn index_project_documents(
info!("Fetched partial projects"); info!("Fetched partial projects");
build_search_documents(pool, redis, db_projects, None).await Ok(build_search_documents(pool, redis, db_projects)
.await?
.projects)
} }
pub async fn index_project_version_documents( pub async fn index_project_version_documents(
@@ -172,16 +176,33 @@ pub async fn index_project_version_documents(
redis: &RedisPool, redis: &RedisPool,
project_ids: &[ProjectId], project_ids: &[ProjectId],
version_ids: &[VersionId], version_ids: &[VersionId],
) -> eyre::Result<Vec<UploadSearchProject>> { ) -> eyre::Result<SearchDocumentBatch> {
let projects =
index_project_document_batch(pool, redis, project_ids).await?;
let version_ids = version_ids
.iter()
.map(ToString::to_string)
.collect::<HashSet<_>>();
Ok(SearchDocumentBatch {
projects: projects.projects,
versions: projects
.versions
.into_iter()
.filter(|version| version_ids.contains(&version.version_id))
.collect(),
})
}
async fn index_project_document_batch(
pool: &PgPool,
redis: &RedisPool,
project_ids: &[ProjectId],
) -> eyre::Result<SearchDocumentBatch> {
let searchable_statuses = searchable_statuses(); let searchable_statuses = searchable_statuses();
let project_ids = project_ids let project_ids = project_ids
.iter() .iter()
.map(|project_id| DBProjectId::from(*project_id).0) .map(|project_id| DBProjectId::from(*project_id).0)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let version_ids = version_ids
.iter()
.map(|version_id| DBVersionId::from(*version_id))
.collect::<HashSet<_>>();
let db_projects = sqlx::query!( let db_projects = sqlx::query!(
r#" r#"
@@ -215,15 +236,14 @@ pub async fn index_project_version_documents(
.await .await
.wrap_err("failed to fetch project")?; .wrap_err("failed to fetch project")?;
build_search_documents(pool, redis, db_projects, Some(&version_ids)).await build_search_documents(pool, redis, db_projects).await
} }
async fn build_search_documents( async fn build_search_documents(
pool: &PgPool, pool: &PgPool,
redis: &RedisPool, redis: &RedisPool,
db_projects: Vec<PartialProject>, db_projects: Vec<PartialProject>,
version_ids_to_index: Option<&HashSet<DBVersionId>>, ) -> eyre::Result<SearchDocumentBatch> {
) -> eyre::Result<Vec<UploadSearchProject>> {
let searchable_statuses = searchable_statuses(); let searchable_statuses = searchable_statuses();
let project_ids = db_projects.iter().map(|x| x.id.0).collect::<Vec<i64>>(); let project_ids = db_projects.iter().map(|x| x.id.0).collect::<Vec<i64>>();
let project_components = db_projects let project_components = db_projects
@@ -391,7 +411,7 @@ async fn build_search_documents(
.await?; .await?;
info!("Getting all loader fields!"); info!("Getting all loader fields!");
let loader_fields: Vec<QueryLoaderField> = sqlx::query!( let loader_field_definitions: Vec<QueryLoaderField> = sqlx::query!(
" "
SELECT DISTINCT id, field, field_type, enum_type, min_val, max_val, optional SELECT DISTINCT id, field, field_type, enum_type, min_val, max_val, optional
FROM loader_fields lf FROM loader_fields lf
@@ -409,7 +429,8 @@ async fn build_search_documents(
}) })
.try_collect() .try_collect()
.await?; .await?;
let loader_fields: Vec<&QueryLoaderField> = loader_fields.iter().collect(); let loader_field_definitions: Vec<&QueryLoaderField> =
loader_field_definitions.iter().collect();
info!("Getting all loader field enum values!"); info!("Getting all loader field enum values!");
@@ -434,7 +455,8 @@ async fn build_search_documents(
.await?; .await?;
info!("Indexing loaders, project types!"); info!("Indexing loaders, project types!");
let mut uploads = Vec::new(); let mut project_uploads = Vec::new();
let mut version_uploads = Vec::new();
let total_len = db_projects.len(); let total_len = db_projects.len();
let mut count = 0; let mut count = 0;
@@ -533,21 +555,34 @@ async fn build_search_documents(
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if let Some(versions) = versions.remove(&project.id) { if let Some(versions) = versions.remove(&project.id) {
// Aggregated project loader fields let Some(latest_version) = versions.iter().max_by(|a, b| {
a.date_published
.cmp(&b.date_published)
.then_with(|| a.id.0.cmp(&b.id.0))
}) else {
continue;
};
let project_version_fields = versions let project_version_fields = versions
.iter() .iter()
.flat_map(|x| x.version_fields.clone()) .flat_map(|x| x.version_fields.clone())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let aggregated_version_fields = VersionField::from_query_json( let aggregated_version_fields = VersionField::from_query_json(
project_version_fields, project_version_fields,
&loader_fields, &loader_field_definitions,
&loader_field_enum_values, &loader_field_enum_values,
true, true,
); );
let project_loader_fields = let unvectorized_loader_fields = aggregated_version_fields
.iter()
.map(|field| {
(field.field_name.clone(), field.value.serialize_internal())
})
.collect();
let mut loader_fields =
from_duplicate_version_fields(aggregated_version_fields); from_duplicate_version_fields(aggregated_version_fields);
let project_loader_fields = loader_fields.clone();
// aggregated project loaders
let mut project_loaders = versions let mut project_loaders = versions
.iter() .iter()
.flat_map(|x| x.loaders.clone()) .flat_map(|x| x.loaders.clone())
@@ -555,162 +590,184 @@ async fn build_search_documents(
project_loaders.sort(); project_loaders.sort();
project_loaders.dedup(); project_loaders.dedup();
// all valid project types across every version of the project, so that let mut project_types = versions
// filters can exclude projects that have *any* version of a given
// project type (unlike the version-specific `project_types` field).
let mut all_project_types = versions
.iter() .iter()
.flat_map(|x| x.project_types.clone()) .flat_map(|x| x.project_types.clone())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
all_project_types.sort(); project_types.sort();
all_project_types.dedup(); project_types.dedup();
exp::compat::correct_project_types( exp::compat::correct_project_types(
&project.components, &project.components,
&mut all_project_types, &mut project_types,
); );
for version in versions { let project_id = ProjectId::from(project.id).to_string();
if let Some(version_ids_to_index) = version_ids_to_index version_uploads.extend(versions.iter().map(|version| {
&& !version_ids_to_index.contains(&version.id)
{
continue;
}
let version_fields = VersionField::from_query_json( let version_fields = VersionField::from_query_json(
version.version_fields, version.version_fields.clone(),
&loader_fields, &loader_field_definitions,
&loader_field_enum_values, &loader_field_enum_values,
false, false,
); );
let unvectorized_loader_fields = version_fields let unvectorized_loader_fields = version_fields
.iter() .iter()
.map(|vf| { .map(|field| {
(vf.field_name.clone(), vf.value.serialize_internal()) (
field.field_name.clone(),
field.value.serialize_internal(),
)
}) })
.collect(); .collect();
let mut loader_fields = let mut fields = from_duplicate_version_fields(version_fields);
from_duplicate_version_fields(version_fields); let mut version_project_types = version.project_types.clone();
let mut project_types = version.project_types;
exp::compat::correct_project_types( exp::compat::correct_project_types(
&project.components, &project.components,
&mut project_types, &mut version_project_types,
); );
let mut version_loaders = version.loaders; let mut version_categories = version.loaders.clone();
let mrpack_loaders = fields
// Uses version loaders, not project loaders.
let mut categories = categories.clone();
categories.append(&mut version_loaders.clone());
let display_categories = display_categories.clone();
categories.append(&mut version_loaders);
// SPECIAL BEHAVIOUR
// Todo: revisit.
// For consistency with v2 searching, we consider the loader field 'mrpack_loaders' to be a category.
// These were previously considered the loader, and in v2, the loader is a category for searching.
// So to avoid breakage or awkward conversions, we just consider those loader_fields to be categories.
// The loaders are kept in loader_fields as well, so that no information is lost on retrieval.
let mrpack_loaders = loader_fields
.get("mrpack_loaders") .get("mrpack_loaders")
.cloned() .into_iter()
.map(|x| { .flatten()
x.into_iter() .filter_map(|value| value.as_str().map(String::from))
.filter_map(|x| x.as_str().map(String::from)) .collect::<Vec<_>>();
.collect::<Vec<_>>() version_categories.extend(mrpack_loaders);
}) if fields.contains_key("mrpack_loaders") {
.unwrap_or_default(); version_categories.retain(|category| category != "mrpack");
categories.extend(mrpack_loaders);
if loader_fields.contains_key("mrpack_loaders") {
categories.retain(|x| *x != "mrpack");
} }
version_categories.sort();
version_categories.dedup();
// SPECIAL BEHAVIOUR:
// For consistency with v2 searching, we manually input the
// client_side and server_side fields from the loader fields into
// separate loader fields.
// 'client_side' and 'server_side' remain supported by meilisearch even though they are no longer v3 fields.
let (_, v2_og_project_type) = let (_, v2_og_project_type) =
LegacyProject::get_project_type(&project_types); LegacyProject::get_project_type(&version_project_types);
let (client_side, server_side) = let (client_side, server_side) =
v2_reroute::convert_v3_side_types_to_v2_side_types( v2_reroute::convert_v3_side_types_to_v2_side_types(
&unvectorized_loader_fields, &unvectorized_loader_fields,
Some(&v2_og_project_type), Some(&v2_og_project_type),
); );
if let Ok(client_side) = serde_json::to_value(client_side) { if let Ok(client_side) = serde_json::to_value(client_side) {
loader_fields fields.insert("client_side".to_string(), vec![client_side]);
.insert("client_side".to_string(), vec![client_side]);
} }
if let Ok(server_side) = serde_json::to_value(server_side) { if let Ok(server_side) = serde_json::to_value(server_side) {
loader_fields fields.insert("server_side".to_string(), vec![server_side]);
.insert("server_side".to_string(), vec![server_side]);
} }
fields.retain(|field, _| {
let components = project matches!(
.components field.as_str(),
.clone() "environment"
.into_query( | "game_versions"
ProjectId::from(project.id), | "client_side"
&project_query_context, | "server_side"
) )
.wrap_err("failed to populate query components")?; });
let usp = UploadSearchProject { UploadSearchVersion {
version_id: crate::models::ids::VersionId::from(version.id) version_id: VersionId::from(version.id).to_string(),
.to_string(), project_id: project_id.clone(),
project_id: crate::models::ids::ProjectId::from(project.id) categories: version_categories,
.to_string(), project_types: version_project_types,
name: project.name.clone(),
indexed_name: normalize_for_search(&project.name),
summary: project.summary.clone(),
categories: categories.clone(),
display_categories: display_categories.clone(),
follows: project.follows,
downloads: project.downloads,
log_downloads: (project.downloads.max(1) as f64).ln(),
icon_url: project.icon_url.clone(),
author: username.clone(),
author_id: ariadne::ids::UserId::from(user_id).to_string(),
organization: org_name.clone(),
organization_id: org_id.map(|e| {
crate::models::ids::OrganizationId::from(e).to_string()
}),
indexed_author: normalize_for_search(&username),
date_created: project.approved,
created_timestamp: project.approved.timestamp(),
date_modified: project.updated,
modified_timestamp: project.updated.timestamp(),
version_published_timestamp: version version_published_timestamp: version
.date_published .date_published
.timestamp(), .timestamp(),
license: license.clone(), loader_fields: fields,
slug: project.slug.clone(), }
// TODO }));
project_types,
all_project_types: all_project_types.clone(),
gallery: gallery.clone(),
featured_gallery: featured_gallery.clone(),
open_source,
color: project.color.map(|x| x as u32),
dependency_project_ids: dependency_project_ids.clone(),
compatible_dependency_project_ids:
compatible_dependency_project_ids.clone(),
dependencies: dependencies.clone(),
loader_fields,
project_loader_fields: project_loader_fields.clone(),
// 'loaders' is aggregate of all versions' loaders
loaders: project_loaders.clone(),
components,
};
uploads.push(usp); let project_categories = categories.clone();
let mut categories = categories;
categories.extend(project_loaders.iter().cloned());
let mrpack_loaders = loader_fields
.get("mrpack_loaders")
.into_iter()
.flatten()
.filter_map(|value| value.as_str().map(String::from))
.collect::<Vec<_>>();
categories.extend(mrpack_loaders);
if loader_fields.contains_key("mrpack_loaders") {
categories.retain(|category| category != "mrpack");
} }
categories.sort();
categories.dedup();
let (_, v2_og_project_type) =
LegacyProject::get_project_type(&project_types);
let (client_side, server_side) =
v2_reroute::convert_v3_side_types_to_v2_side_types(
&unvectorized_loader_fields,
Some(&v2_og_project_type),
);
if let Ok(client_side) = serde_json::to_value(client_side) {
loader_fields
.insert("client_side".to_string(), vec![client_side]);
}
if let Ok(server_side) = serde_json::to_value(server_side) {
loader_fields
.insert("server_side".to_string(), vec![server_side]);
}
let components = project
.components
.clone()
.into_query(ProjectId::from(project.id), &project_query_context)
.wrap_err("failed to populate query components")?;
let indexed_name = normalize_for_search(&project.name);
project_uploads.push(UploadSearchProject {
version_id: crate::models::ids::VersionId::from(
latest_version.id,
)
.to_string(),
project_id,
name: project.name,
indexed_name,
summary: project.summary,
categories,
project_categories,
display_categories,
follows: project.follows,
downloads: project.downloads,
log_downloads: (project.downloads.max(1) as f64).ln(),
icon_url: project.icon_url,
author: username.clone(),
author_id: ariadne::ids::UserId::from(user_id).to_string(),
organization: org_name,
organization_id: org_id.map(|id| {
crate::models::ids::OrganizationId::from(id).to_string()
}),
indexed_author: normalize_for_search(&username),
date_created: project.approved,
created_timestamp: project.approved.timestamp(),
date_modified: project.updated,
modified_timestamp: project.updated.timestamp(),
version_published_timestamp: latest_version
.date_published
.timestamp(),
license,
slug: project.slug,
project_types: project_types.clone(),
all_project_types: project_types,
gallery,
featured_gallery,
open_source,
color: project.color.map(|x| x as u32),
dependency_project_ids,
compatible_dependency_project_ids,
dependencies,
project_loader_fields,
loader_fields,
loaders: project_loaders,
components,
});
} }
} }
Ok(uploads) Ok(SearchDocumentBatch {
projects: project_uploads,
versions: version_uploads,
})
} }
struct PartialVersion { struct PartialVersion {
+35 -2
View File
@@ -116,12 +116,25 @@ pub trait SearchBackend: Send + Sync {
documents: &[UploadSearchProject], documents: &[UploadSearchProject],
) -> eyre::Result<()>; ) -> eyre::Result<()>;
async fn index_version_documents(
&self,
documents: &[UploadSearchVersion],
) -> eyre::Result<()>;
async fn remove_project_documents( async fn remove_project_documents(
&self, &self,
ids: &[ProjectId], ids: &[ProjectId],
) -> eyre::Result<()>; ) -> eyre::Result<()>;
async fn remove_documents(&self, ids: &[VersionId]) -> eyre::Result<()>; async fn remove_project_version_documents(
&self,
ids: &[ProjectId],
) -> eyre::Result<()>;
async fn remove_version_documents(
&self,
ids: &[VersionId],
) -> eyre::Result<()>;
async fn tasks(&self) -> eyre::Result<Value>; async fn tasks(&self) -> eyre::Result<Value>;
@@ -238,6 +251,7 @@ impl FromStr for SearchBackendKind {
/// serialized as `null`. /// serialized as `null`.
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct UploadSearchProject { pub struct UploadSearchProject {
/// ID of the most recently published version.
pub version_id: String, pub version_id: String,
pub project_id: String, pub project_id: String,
// //
@@ -256,6 +270,7 @@ pub struct UploadSearchProject {
pub indexed_name: String, pub indexed_name: String,
pub summary: String, pub summary: String,
pub categories: Vec<String>, pub categories: Vec<String>,
pub project_categories: Vec<String>,
pub display_categories: Vec<String>, pub display_categories: Vec<String>,
pub follows: i32, pub follows: i32,
pub downloads: i32, pub downloads: i32,
@@ -274,7 +289,7 @@ pub struct UploadSearchProject {
pub date_modified: DateTime<Utc>, pub date_modified: DateTime<Utc>,
/// Unix timestamp of the last major modification /// Unix timestamp of the last major modification
pub modified_timestamp: i64, pub modified_timestamp: i64,
/// Unix timestamp of the publication date of the version /// Unix timestamp of the most recently published version.
pub version_published_timestamp: i64, pub version_published_timestamp: i64,
pub open_source: bool, pub open_source: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -296,6 +311,23 @@ pub struct UploadSearchProject {
pub loader_fields: HashMap<String, Vec<serde_json::Value>>, pub loader_fields: HashMap<String, Vec<serde_json::Value>>,
} }
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct UploadSearchVersion {
pub version_id: String,
pub project_id: String,
pub categories: Vec<String>,
pub project_types: Vec<String>,
pub version_published_timestamp: i64,
#[serde(flatten)]
pub loader_fields: HashMap<String, Vec<serde_json::Value>>,
}
#[derive(Debug, Default)]
pub struct SearchDocumentBatch {
pub projects: Vec<UploadSearchProject>,
pub versions: Vec<UploadSearchVersion>,
}
/// Nullable fields in Typesense-bound documents should use /// Nullable fields in Typesense-bound documents should use
/// `skip_serializing_if = "Option::is_none"` so they are omitted instead of /// `skip_serializing_if = "Option::is_none"` so they are omitted instead of
/// serialized as `null`. /// serialized as `null`.
@@ -320,6 +352,7 @@ pub struct SearchResults {
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] #[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
pub struct ResultSearchProject { pub struct ResultSearchProject {
/// ID of the most recently published version.
pub version_id: String, pub version_id: String,
pub project_id: String, pub project_id: String,
pub project_types: Vec<String>, pub project_types: Vec<String>,
+443
View File
@@ -0,0 +1,443 @@
#!/usr/bin/env python3
"""Split legacy Typesense JSONL into project and version documents."""
import argparse
import json
import os
import shutil
import sys
import tempfile
import time
import zlib
from collections import OrderedDict
VERSION_FILTER_PATHS = (
"project_types",
"environment",
"game_versions",
"client_side",
"server_side",
)
BASE62_DIGITS = {
character: index
for index, character in enumerate(
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
)
}
class ConversionError(Exception):
pass
def parse_args():
parser = argparse.ArgumentParser(
description=(
"Convert a legacy Typesense JSONL export from one document per "
"version into separate project and version collections."
)
)
parser.add_argument("input", help="Legacy Typesense JSONL export")
parser.add_argument("projects_output", help="Destination project-document JSONL")
parser.add_argument("versions_output", help="Destination version-document JSONL")
parser.add_argument(
"--shards",
type=int,
default=512,
help="Temporary hash shards used to bound memory usage (default: 512)",
)
parser.add_argument(
"--max-open-shards",
type=int,
default=64,
help="Maximum temporary shard files held open at once (default: 64)",
)
parser.add_argument(
"--progress-interval",
type=float,
default=10.0,
help="Seconds between progress reports (default: 10)",
)
parser.add_argument(
"--keep-temporary",
action="store_true",
help="Keep temporary shard files after completion or failure",
)
return parser.parse_args()
def base62_value(value):
result = 0
try:
for character in value:
result = result * 62 + BASE62_DIGITS[character]
except KeyError:
return -1
return result
def get_path(document, path):
value = document
for segment in path.split("."):
if not isinstance(value, dict) or segment not in value:
return None, False
value = value[segment]
return value, True
def set_path(document, path, value):
segments = path.split(".")
current = document
for segment in segments[:-1]:
child = current.get(segment)
if not isinstance(child, dict):
child = {}
current[segment] = child
current = child
current[segments[-1]] = value
def unique_sorted(values):
return sorted(set(values))
def extend_values(target, value):
if isinstance(value, list):
target.extend(value)
elif value is not None:
target.append(value)
def version_filter_document(document):
version_id = document.get("version_id") or document.get("id")
result = {
"id": str(version_id),
"version_id": str(version_id),
"project_id": str(document["project_id"]),
"version_published_timestamp": document.get(
"version_published_timestamp", -1
),
}
loaders = list(document.get("loaders") or [])
mrpack_loaders = list(document.get("mrpack_loaders") or [])
loaders.extend(mrpack_loaders)
if mrpack_loaders:
loaders = [loader for loader in loaders if loader != "mrpack"]
result["categories"] = unique_sorted(loaders)
for path in VERSION_FILTER_PATHS:
value, exists = get_path(document, path)
if exists and value is not None:
set_path(result, path, value)
return result
class ProjectAccumulator:
def __init__(self, document):
self.latest_document = document
self.latest_key = self.version_key(document)
self.versions = {}
self.categories = []
self.version_categories = []
self.loaders = []
self.project_types = []
self.client_side = []
self.server_side = []
self.add(document)
@staticmethod
def version_key(document):
return (
document.get("version_published_timestamp", -1),
base62_value(str(document.get("version_id", ""))),
)
def add(self, document):
project_id = document.get("project_id")
if project_id != self.latest_document.get("project_id"):
raise ConversionError("attempted to combine different projects")
version_id = document.get("version_id") or document.get("id")
if not version_id:
raise ConversionError(f"project `{project_id}` has a version without an ID")
key = self.version_key(document)
if key > self.latest_key:
self.latest_document = document
self.latest_key = key
version_document = version_filter_document(document)
self.versions[str(version_id)] = (key, version_document)
extend_values(self.categories, document.get("categories"))
extend_values(self.version_categories, version_document.get("categories"))
extend_values(self.loaders, document.get("loaders"))
extend_values(self.project_types, document.get("project_types"))
extend_values(self.project_types, document.get("all_project_types"))
extend_values(self.client_side, document.get("client_side"))
extend_values(self.server_side, document.get("server_side"))
def finish(self):
result = dict(self.latest_document)
project_id = str(result["project_id"])
all_project_types = unique_sorted(self.project_types)
result["id"] = project_id
result["version_id"] = str(
self.latest_document.get("version_id")
or self.latest_document.get("id")
)
result["categories"] = unique_sorted(self.categories)
result["project_categories"] = unique_sorted(
set(self.categories) - set(self.version_categories)
)
result["loaders"] = unique_sorted(self.loaders)
result["project_types"] = all_project_types
result["all_project_types"] = all_project_types
project_loader_fields = result.get("project_loader_fields")
if not isinstance(project_loader_fields, dict):
project_loader_fields = {}
result["project_loader_fields"] = project_loader_fields
for field, value in project_loader_fields.items():
result[field] = value
if self.client_side:
result["client_side"] = unique_sorted(self.client_side)
if self.server_side:
result["server_side"] = unique_sorted(self.server_side)
versions = [
version
for _, version in sorted(
self.versions.values(), key=lambda item: item[0]
)
]
result.pop("versions", None)
return result, versions
class ShardWriter:
def __init__(self, directory, shard_count, max_open):
self.directory = directory
self.shard_count = shard_count
self.max_open = max_open
self.handles = OrderedDict()
def path(self, shard):
return os.path.join(self.directory, f"shard-{shard:04d}.jsonl")
def write(self, project_id, line):
shard = zlib.crc32(project_id.encode("utf-8")) % self.shard_count
handle = self.handles.pop(shard, None)
if handle is None:
if len(self.handles) >= self.max_open:
_, oldest = self.handles.popitem(last=False)
oldest.close()
handle = open(self.path(shard), "ab")
self.handles[shard] = handle
handle.write(line)
def close(self):
for handle in self.handles.values():
handle.close()
self.handles.clear()
def shard_input(args, temporary_directory):
writer = ShardWriter(
temporary_directory,
args.shards,
args.max_open_shards,
)
input_size = os.path.getsize(args.input)
bytes_read = 0
document_count = 0
started_at = time.monotonic()
last_report = started_at
try:
with open(args.input, "rb") as input_file:
for line_number, line in enumerate(input_file, start=1):
bytes_read += len(line)
if not line.strip():
continue
try:
document = json.loads(line)
except json.JSONDecodeError as error:
raise ConversionError(
f"invalid JSON on input line {line_number}: {error}"
) from error
project_id = document.get("project_id")
if not project_id:
raise ConversionError(
f"input line {line_number} has no `project_id`"
)
writer.write(str(project_id), line)
document_count += 1
now = time.monotonic()
if now - last_report >= args.progress_interval:
percent = bytes_read / input_size * 100 if input_size else 100
print(
f"sharding: {percent:.1f}% ({document_count:,} documents)",
flush=True,
)
last_report = now
finally:
writer.close()
print(
f"sharding complete: {document_count:,} version documents in "
f"{time.monotonic() - started_at:.1f}s",
flush=True,
)
return document_count
def convert_shards(
args,
temporary_directory,
partial_projects_output,
partial_versions_output,
):
project_count = 0
version_count = 0
started_at = time.monotonic()
last_report = started_at
with (
open(partial_projects_output, "w", encoding="utf-8") as projects_file,
open(partial_versions_output, "w", encoding="utf-8") as versions_file,
):
for shard in range(args.shards):
path = os.path.join(temporary_directory, f"shard-{shard:04d}.jsonl")
if not os.path.exists(path):
continue
projects = {}
with open(path, "rb") as shard_file:
for line_number, line in enumerate(shard_file, start=1):
try:
document = json.loads(line)
except json.JSONDecodeError as error:
raise ConversionError(
f"invalid JSON in shard {shard}, line {line_number}: {error}"
) from error
project_id = str(document["project_id"])
if project_id in projects:
projects[project_id].add(document)
else:
projects[project_id] = ProjectAccumulator(document)
version_count += 1
for project_id in sorted(projects):
project, versions = projects[project_id].finish()
json.dump(
project,
projects_file,
separators=(",", ":"),
ensure_ascii=False,
)
projects_file.write("\n")
for version in versions:
json.dump(
version,
versions_file,
separators=(",", ":"),
ensure_ascii=False,
)
versions_file.write("\n")
project_count += 1
os.remove(path)
now = time.monotonic()
if now - last_report >= args.progress_interval:
print(
f"converting: shard {shard + 1}/{args.shards}, "
f"{project_count:,} projects",
flush=True,
)
last_report = now
print(
f"conversion complete: {version_count:,} versions into "
f"{project_count:,} projects in {time.monotonic() - started_at:.1f}s",
flush=True,
)
return project_count, version_count
def main():
args = parse_args()
if args.shards <= 0:
raise ConversionError("--shards must be greater than zero")
if args.max_open_shards <= 0:
raise ConversionError("--max-open-shards must be greater than zero")
if args.progress_interval <= 0:
raise ConversionError("--progress-interval must be greater than zero")
if not os.path.isfile(args.input):
raise ConversionError(f"input file does not exist: {args.input}")
paths = {
os.path.abspath(args.input),
os.path.abspath(args.projects_output),
os.path.abspath(args.versions_output),
}
if len(paths) != 3:
raise ConversionError("input and output paths must all differ")
projects_directory = os.path.dirname(os.path.abspath(args.projects_output))
versions_directory = os.path.dirname(os.path.abspath(args.versions_output))
os.makedirs(projects_directory, exist_ok=True)
os.makedirs(versions_directory, exist_ok=True)
temporary_directory = tempfile.mkdtemp(
prefix="typesense-project-convert-",
dir=projects_directory,
)
partial_projects_output = f"{args.projects_output}.partial"
partial_versions_output = f"{args.versions_output}.partial"
try:
expected_versions = shard_input(args, temporary_directory)
project_count, version_count = convert_shards(
args,
temporary_directory,
partial_projects_output,
partial_versions_output,
)
if version_count != expected_versions:
raise ConversionError(
f"sharded {expected_versions} versions but converted {version_count}"
)
os.replace(partial_projects_output, args.projects_output)
os.replace(partial_versions_output, args.versions_output)
print(
f"wrote {project_count:,} project documents to "
f"{args.projects_output} and {version_count:,} version documents to "
f"{args.versions_output}",
flush=True,
)
except Exception:
for partial_output in (
partial_projects_output,
partial_versions_output,
):
if os.path.exists(partial_output):
os.remove(partial_output)
raise
finally:
if args.keep_temporary:
print(f"temporary shards kept at {temporary_directory}", file=sys.stderr)
else:
shutil.rmtree(temporary_directory, ignore_errors=True)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("interrupted", file=sys.stderr)
sys.exit(130)
except (ConversionError, OSError) as error:
print(error, file=sys.stderr)
sys.exit(1)