From 64858b4d3e3953e9a3a9c04359c6826087e966de Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Tue, 1 Jul 2025 14:20:37 +0200 Subject: [PATCH 1/6] CI: Use absolute path for `TYPST_FONT_PATH` environment variable This allows us to also use it in the main crate test suite. --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 326b37f6e17..cf0a2b8b9c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -206,8 +206,7 @@ jobs: - run: cargo test --workspace env: # Set the path to the Fira Sans font for Typst. - # The path is relative to the `crates_io_og_image` crate root. - TYPST_FONT_PATH: ../../Fira-4.202/otf + TYPST_FONT_PATH: ${{ github.workspace }}/Fira-4.202/otf frontend-lint: name: Frontend / Lint From 6c9da8e57dee55e7278d2415cb7c6010feace04e Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Mon, 30 Jun 2025 16:12:57 +0200 Subject: [PATCH 2/6] storage: Implement OG image storage functionality --- src/storage.rs | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/storage.rs b/src/storage.rs index f948483f8fa..96e7de501aa 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -19,15 +19,18 @@ use tracing::{instrument, warn}; const PREFIX_CRATES: &str = "crates"; const PREFIX_READMES: &str = "readmes"; +const PREFIX_OG_IMAGES: &str = "og-images"; const DEFAULT_REGION: &str = "us-west-1"; const CONTENT_TYPE_CRATE: &str = "application/gzip"; const CONTENT_TYPE_GZIP: &str = "application/gzip"; const CONTENT_TYPE_ZIP: &str = "application/zip"; const CONTENT_TYPE_INDEX: &str = "text/plain"; const CONTENT_TYPE_README: &str = "text/html"; +const CONTENT_TYPE_OG_IMAGE: &str = "image/png"; const CACHE_CONTROL_IMMUTABLE: &str = "public,max-age=31536000,immutable"; const CACHE_CONTROL_INDEX: &str = "public,max-age=600"; const CACHE_CONTROL_README: &str = "public,max-age=604800"; +const CACHE_CONTROL_OG_IMAGE: &str = "public,max-age=86400"; type StdPath = std::path::Path; @@ -209,6 +212,13 @@ impl Storage { apply_cdn_prefix(&self.cdn_prefix, &readme_path(name, version)).replace('+', "%2B") } + /// Returns the URL of an uploaded crate's Open Graph image. + /// + /// The function doesn't check for the existence of the file. + pub fn og_image_location(&self, name: &str) -> String { + apply_cdn_prefix(&self.cdn_prefix, &og_image_path(name)) + } + /// Returns the URL of an uploaded RSS feed. pub fn feed_url(&self, feed_id: &FeedId<'_>) -> String { apply_cdn_prefix(&self.cdn_prefix, &feed_id.into()).replace('+', "%2B") @@ -240,6 +250,13 @@ impl Storage { self.store.delete(&path).await } + /// Deletes the Open Graph image for the given crate. + #[instrument(skip(self))] + pub async fn delete_og_image(&self, name: &str) -> Result<()> { + let path = og_image_path(name); + self.store.delete(&path).await + } + #[instrument(skip(self))] pub async fn delete_feed(&self, feed_id: &FeedId<'_>) -> Result<()> { let path = feed_id.into(); @@ -270,6 +287,19 @@ impl Storage { Ok(()) } + /// Uploads an Open Graph image for the given crate. + #[instrument(skip(self, bytes))] + pub async fn upload_og_image(&self, name: &str, bytes: Bytes) -> Result<()> { + let path = og_image_path(name); + let attributes = self.attrs([ + (Attribute::ContentType, CONTENT_TYPE_OG_IMAGE), + (Attribute::CacheControl, CACHE_CONTROL_OG_IMAGE), + ]); + let opts = attributes.into(); + self.store.put_opts(&path, bytes.into(), opts).await?; + Ok(()) + } + #[instrument(skip(self, channel))] pub async fn upload_feed( &self, @@ -385,6 +415,10 @@ fn readme_path(name: &str, version: &str) -> Path { format!("{PREFIX_READMES}/{name}/{name}-{version}.html").into() } +fn og_image_path(name: &str) -> Path { + format!("{PREFIX_OG_IMAGES}/{name}.png").into() +} + fn apply_cdn_prefix(cdn_prefix: &Option, path: &Path) -> String { match cdn_prefix { Some(cdn_prefix) if !cdn_prefix.starts_with("https://") => { @@ -484,6 +518,17 @@ mod tests { for (name, version, expected) in readme_tests { assert_eq!(storage.readme_location(name, version), expected); } + + let og_image_tests = vec![ + ("foo", "https://static.crates.io/og-images/foo.png"), + ( + "some-long-crate-name", + "https://static.crates.io/og-images/some-long-crate-name.png", + ), + ]; + for (name, expected) in og_image_tests { + assert_eq!(storage.og_image_location(name), expected); + } } #[test] @@ -661,4 +706,39 @@ mod tests { let expected_files = vec![target]; assert_eq!(stored_files(&s.store).await, expected_files); } + + #[tokio::test] + async fn upload_og_image() { + let s = Storage::from_config(&StorageConfig::in_memory()); + + let bytes = Bytes::from_static(b"fake png data"); + s.upload_og_image("foo", bytes.clone()).await.unwrap(); + + let expected_files = vec!["og-images/foo.png"]; + assert_eq!(stored_files(&s.store).await, expected_files); + + s.upload_og_image("some-long-crate-name", bytes) + .await + .unwrap(); + + let expected_files = vec!["og-images/foo.png", "og-images/some-long-crate-name.png"]; + assert_eq!(stored_files(&s.store).await, expected_files); + } + + #[tokio::test] + async fn delete_og_image() { + let s = Storage::from_config(&StorageConfig::in_memory()); + + let bytes = Bytes::from_static(b"fake png data"); + s.upload_og_image("foo", bytes.clone()).await.unwrap(); + s.upload_og_image("bar", bytes).await.unwrap(); + + let expected_files = vec!["og-images/bar.png", "og-images/foo.png"]; + assert_eq!(stored_files(&s.store).await, expected_files); + + s.delete_og_image("foo").await.unwrap(); + + let expected_files = vec!["og-images/bar.png"]; + assert_eq!(stored_files(&s.store).await, expected_files); + } } From e49f92fd681441f33775ec5b31ad986b3405a9cd Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Mon, 30 Jun 2025 18:00:21 +0200 Subject: [PATCH 3/6] worker: Add OpenGraph image generation background job --- Cargo.lock | 1 + Cargo.toml | 1 + src/bin/background-worker.rs | 2 + src/tests/util/test_app.rs | 11 + src/worker/environment.rs | 2 + src/worker/jobs/generate_og_image.rs | 238 ++++++++++++++++++ src/worker/jobs/mod.rs | 2 + ...s__generate_og_image__tests__og-image.snap | 6 + ...enerate_og_image__tests__og-image.snap.png | Bin 0 -> 31844 bytes src/worker/mod.rs | 1 + 10 files changed, 264 insertions(+) create mode 100644 src/worker/jobs/generate_og_image.rs create mode 100644 src/worker/jobs/snapshots/crates_io__worker__jobs__generate_og_image__tests__og-image.snap create mode 100644 src/worker/jobs/snapshots/crates_io__worker__jobs__generate_og_image__tests__og-image.snap.png diff --git a/Cargo.lock b/Cargo.lock index 7169b712823..6848c788cd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1376,6 +1376,7 @@ dependencies = [ "crates_io_github", "crates_io_index", "crates_io_markdown", + "crates_io_og_image", "crates_io_pagerduty", "crates_io_session", "crates_io_tarball", diff --git a/Cargo.toml b/Cargo.toml index 4d674bc3ab5..86ade68d932 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,7 @@ crates_io_env_vars = { path = "crates/crates_io_env_vars" } crates_io_github = { path = "crates/crates_io_github" } crates_io_index = { path = "crates/crates_io_index" } crates_io_markdown = { path = "crates/crates_io_markdown" } +crates_io_og_image = { path = "crates/crates_io_og_image" } crates_io_pagerduty = { path = "crates/crates_io_pagerduty" } crates_io_session = { path = "crates/crates_io_session" } crates_io_tarball = { path = "crates/crates_io_tarball" } diff --git a/src/bin/background-worker.rs b/src/bin/background-worker.rs index be1d149cda9..f1146b7530d 100644 --- a/src/bin/background-worker.rs +++ b/src/bin/background-worker.rs @@ -24,6 +24,7 @@ use crates_io::{Emails, config}; use crates_io_docs_rs::RealDocsRsClient; use crates_io_env_vars::var; use crates_io_index::RepositoryConfig; +use crates_io_og_image::OgImageGenerator; use crates_io_team_repo::TeamRepoImpl; use crates_io_worker::Runner; use object_store::prefix::PrefixStore; @@ -102,6 +103,7 @@ fn main() -> anyhow::Result<()> { .emails(emails) .maybe_docs_rs(docs_rs) .team_repo(Box::new(team_repo)) + .og_image_generator(OgImageGenerator::from_environment()?) .build(); let environment = Arc::new(environment); diff --git a/src/tests/util/test_app.rs b/src/tests/util/test_app.rs index cf0a8d21f7e..4f2370f7c95 100644 --- a/src/tests/util/test_app.rs +++ b/src/tests/util/test_app.rs @@ -16,6 +16,7 @@ use crates_io_docs_rs::MockDocsRsClient; use crates_io_github::MockGitHubClient; use crates_io_index::testing::UpstreamIndex; use crates_io_index::{Credentials, RepositoryConfig}; +use crates_io_og_image::OgImageGenerator; use crates_io_team_repo::MockTeamRepo; use crates_io_test_db::TestDatabase; use crates_io_trustpub::github::test_helpers::AUDIENCE; @@ -107,6 +108,7 @@ impl TestApp { github: None, docs_rs: None, oidc_key_stores: Default::default(), + og_image_generator: None, } } @@ -255,6 +257,7 @@ pub struct TestAppBuilder { github: Option, docs_rs: Option, oidc_key_stores: HashMap>, + og_image_generator: Option, } impl TestAppBuilder { @@ -314,6 +317,7 @@ impl TestAppBuilder { .emails(app.emails.clone()) .maybe_docs_rs(self.docs_rs.map(|cl| Box::new(cl) as _)) .team_repo(Box::new(self.team_repo)) + .maybe_og_image_generator(self.og_image_generator) .build(); let runner = Runner::new(app.primary_database.clone(), Arc::new(environment)) @@ -423,6 +427,13 @@ impl TestAppBuilder { self } + pub fn with_og_image_generator(mut self) -> Self { + let og_generator = OgImageGenerator::from_environment() + .expect("Failed to create OG image generator for tests"); + self.og_image_generator = Some(og_generator); + self + } + pub fn with_replica(mut self) -> Self { let primary = &self.config.db.primary; diff --git a/src/worker/environment.rs b/src/worker/environment.rs index 61dced1fcc2..9b98b5b63bc 100644 --- a/src/worker/environment.rs +++ b/src/worker/environment.rs @@ -7,6 +7,7 @@ use anyhow::Context; use bon::Builder; use crates_io_docs_rs::DocsRsClient; use crates_io_index::{Repository, RepositoryConfig}; +use crates_io_og_image::OgImageGenerator; use crates_io_team_repo::TeamRepo; use diesel_async::AsyncPgConnection; use diesel_async::pooled_connection::deadpool::Pool; @@ -33,6 +34,7 @@ pub struct Environment { pub emails: Emails, pub team_repo: Box, pub docs_rs: Option>, + pub og_image_generator: Option, /// A lazily initialised cache of the most popular crates ready to use in typosquatting checks. #[builder(skip)] diff --git a/src/worker/jobs/generate_og_image.rs b/src/worker/jobs/generate_og_image.rs new file mode 100644 index 00000000000..a5a04d36777 --- /dev/null +++ b/src/worker/jobs/generate_og_image.rs @@ -0,0 +1,238 @@ +use crate::models::OwnerKind; +use crate::schema::*; +use crate::worker::Environment; +use anyhow::Context; +use crates_io_og_image::{OgImageAuthorData, OgImageData}; +use crates_io_worker::BackgroundJob; +use diesel::prelude::*; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::fs; +use tracing::{error, info, instrument, warn}; + +#[derive(Serialize, Deserialize)] +pub struct GenerateOgImage { + crate_name: String, +} + +impl GenerateOgImage { + pub fn new(crate_name: String) -> Self { + Self { crate_name } + } +} + +impl BackgroundJob for GenerateOgImage { + const JOB_NAME: &'static str = "generate_og_image"; + const DEDUPLICATED: bool = true; + + type Context = Arc; + + #[instrument(skip_all, fields(crate.name = %self.crate_name))] + async fn run(&self, ctx: Self::Context) -> anyhow::Result<()> { + let crate_name = &self.crate_name; + + let Some(option) = &ctx.og_image_generator else { + warn!("OG image generator is not configured, skipping job for crate {crate_name}"); + return Ok(()); + }; + + info!("Generating OG image for crate {crate_name}"); + + let mut conn = ctx.deadpool.get().await?; + + // Fetch crate data + let row = fetch_crate_data(crate_name, &mut conn).await; + let row = row.context("Failed to fetch crate data")?; + let Some(row) = row else { + error!("Crate '{crate_name}' not found or has no default version"); + return Ok(()); + }; + + let keywords: Vec<&str> = row.keywords.iter().flatten().map(|k| k.as_str()).collect(); + + // Fetch user owners + let owners = fetch_user_owners(row._crate_id, &mut conn).await; + let owners = owners.context("Failed to fetch crate owners")?; + let authors: Vec> = owners + .iter() + .map(|(login, avatar)| OgImageAuthorData::new(login, avatar.as_ref().map(Into::into))) + .collect(); + + // Build the OG image data + let og_data = OgImageData { + name: &row.crate_name, + version: &row.version_num, + description: row.description.as_deref(), + license: row.license.as_deref(), + tags: &keywords, + authors: &authors, + lines_of_code: None, // We don't track this yet + crate_size: row.crate_size as u32, + releases: row.num_versions as u32, + }; + + // Generate the OG image + let temp_file = option.generate(og_data).await?; + + // Read the generated image + let image_bytes = fs::read(temp_file.path()).await?; + + // Upload to storage + ctx.storage + .upload_og_image(crate_name, image_bytes.into()) + .await?; + + // Invalidate CDN cache for the OG image + let og_image_path = format!("og-images/{crate_name}.png"); + ctx.invalidate_cdns(&og_image_path).await?; + + info!("Successfully generated and uploaded OG image for crate {crate_name}"); + + Ok(()) + } +} + +#[derive(Queryable, Selectable)] +#[diesel(check_for_backend(diesel::pg::Pg))] +struct QueryRow { + #[diesel(select_expression = crates::id)] + _crate_id: i32, + #[diesel(select_expression = crates::name)] + crate_name: String, + #[diesel(select_expression = versions::num)] + version_num: String, + #[diesel(select_expression = versions::description)] + description: Option, + #[diesel(select_expression = versions::license)] + license: Option, + #[diesel(select_expression = versions::crate_size)] + crate_size: i32, + #[diesel(select_expression = versions::keywords)] + keywords: Vec>, + #[diesel(select_expression = default_versions::num_versions.assume_not_null())] + num_versions: i32, +} + +/// Fetches crate data and default version information by crate name +async fn fetch_crate_data( + crate_name: &str, + conn: &mut AsyncPgConnection, +) -> QueryResult> { + crates::table + .inner_join(default_versions::table) + .inner_join(versions::table.on(default_versions::version_id.eq(versions::id))) + .filter(crates::name.eq(crate_name)) + .select(QueryRow::as_select()) + .first(conn) + .await + .optional() +} + +/// Fetches user owners and their avatars for a crate by crate ID +async fn fetch_user_owners( + crate_id: i32, + conn: &mut AsyncPgConnection, +) -> QueryResult)>> { + crate_owners::table + .inner_join(users::table.on(crate_owners::owner_id.eq(users::id))) + .filter(crate_owners::crate_id.eq(crate_id)) + .filter(crate_owners::owner_kind.eq(OwnerKind::User)) + .filter(crate_owners::deleted.eq(false)) + .select((users::gh_login, users::gh_avatar)) + .load(conn) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tests::builders::CrateBuilder; + use crate::tests::util::TestApp; + use claims::{assert_err, assert_ok}; + use crates_io_env_vars::var; + use crates_io_worker::BackgroundJob; + use insta::assert_binary_snapshot; + use std::process::Command; + + fn is_ci() -> bool { + var("CI").unwrap().is_some() + } + + fn typst_available() -> bool { + Command::new("typst").arg("--version").spawn().is_ok() + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_generate_og_image_job() { + let (app, _, user) = TestApp::full().with_og_image_generator().with_user().await; + + if !is_ci() && !typst_available() { + warn!("Skipping OG image generation test because 'typst' is not available"); + return; + } + + let mut conn = app.db_conn().await; + + // Create a test crate with keywords using CrateBuilder + CrateBuilder::new("test-crate", user.as_model().id) + .description("A test crate for OG image generation") + .keyword("testing") + .keyword("rust") + .expect_build(&mut conn) + .await; + + // Create and enqueue the job + let job = GenerateOgImage::new("test-crate".to_string()); + job.enqueue(&mut conn).await.unwrap(); + + // Run the background job + app.run_pending_background_jobs().await; + + // Verify the OG image was uploaded to storage + let storage = app.as_inner().storage.as_inner(); + let og_image_path = "og-images/test-crate.png"; + + // Try to download the image to verify it exists + let download_result = storage.get(&og_image_path.into()).await; + let result = assert_ok!( + download_result, + "OG image should be uploaded to storage at: {og_image_path}" + ); + + // Verify it's a non-empty file + let image_bytes = result.bytes().await.unwrap().to_vec(); + assert!(!image_bytes.is_empty(), "OG image should not be empty"); + + // Verify it starts with PNG magic bytes + assert_eq!( + &image_bytes[0..8], + &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], + "Uploaded file should be a valid PNG" + ); + + assert_binary_snapshot!("og-image.png", image_bytes); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_generate_og_image_job_nonexistent_crate() { + let (app, _, _) = TestApp::full().with_user().await; + let mut conn = app.db_conn().await; + + // Create and enqueue the job for a non-existent crate + let job = GenerateOgImage::new("nonexistent-crate".to_string()); + job.enqueue(&mut conn).await.unwrap(); + + // Run the background job - should complete without error + app.run_pending_background_jobs().await; + + // Verify no OG image was uploaded + let storage = app.as_inner().storage.as_inner(); + let og_image_path = "og-images/nonexistent-crate.png"; + let download_result = storage.get(&og_image_path.into()).await; + assert_err!( + download_result, + "No OG image should be uploaded for nonexistent crate" + ); + } +} diff --git a/src/worker/jobs/mod.rs b/src/worker/jobs/mod.rs index af8a008a652..b38d970365f 100644 --- a/src/worker/jobs/mod.rs +++ b/src/worker/jobs/mod.rs @@ -5,6 +5,7 @@ mod docs_rs_queue_rebuild; mod downloads; pub mod dump_db; mod expiry_notification; +mod generate_og_image; mod index; mod index_version_downloads_archive; mod invalidate_cdns; @@ -25,6 +26,7 @@ pub use self::downloads::{ }; pub use self::dump_db::DumpDb; pub use self::expiry_notification::SendTokenExpiryNotifications; +pub use self::generate_og_image::GenerateOgImage; pub use self::index::{NormalizeIndex, SquashIndex, SyncToGitIndex, SyncToSparseIndex}; pub use self::index_version_downloads_archive::IndexVersionDownloadsArchive; pub use self::invalidate_cdns::InvalidateCdns; diff --git a/src/worker/jobs/snapshots/crates_io__worker__jobs__generate_og_image__tests__og-image.snap b/src/worker/jobs/snapshots/crates_io__worker__jobs__generate_og_image__tests__og-image.snap new file mode 100644 index 00000000000..cf5fa112c7b --- /dev/null +++ b/src/worker/jobs/snapshots/crates_io__worker__jobs__generate_og_image__tests__og-image.snap @@ -0,0 +1,6 @@ +--- +source: src/worker/jobs/generate_og_image.rs +expression: image_bytes +extension: png +snapshot_kind: binary +--- diff --git a/src/worker/jobs/snapshots/crates_io__worker__jobs__generate_og_image__tests__og-image.snap.png b/src/worker/jobs/snapshots/crates_io__worker__jobs__generate_og_image__tests__og-image.snap.png new file mode 100644 index 0000000000000000000000000000000000000000..efc5b0eec397fdfa6085561692b6ebac62597605 GIT binary patch literal 31844 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 EKX(qeTL1t6 literal 0 HcmV?d00001 diff --git a/src/worker/mod.rs b/src/worker/mod.rs index f22bd29d0a6..8bed028cbc1 100644 --- a/src/worker/mod.rs +++ b/src/worker/mod.rs @@ -26,6 +26,7 @@ impl RunnerExt for Runner> { .register_job_type::() .register_job_type::() .register_job_type::() + .register_job_type::() .register_job_type::() .register_job_type::() .register_job_type::() From 6f6ba7ce7d9c40d7fe9691e94946d8fa5ea1415b Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Mon, 30 Jun 2025 19:03:53 +0200 Subject: [PATCH 4/6] publish: Trigger OG image generation after version updates --- src/controllers/krate/publish.rs | 14 +++++++++++--- src/worker/jobs/update_default_version.rs | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/controllers/krate/publish.rs b/src/controllers/krate/publish.rs index e34c0326c60..0aa70e17a94 100644 --- a/src/controllers/krate/publish.rs +++ b/src/controllers/krate/publish.rs @@ -3,7 +3,7 @@ use crate::app::AppState; use crate::auth::{AuthCheck, AuthHeader, Authentication}; use crate::worker::jobs::{ - self, CheckTyposquat, SendPublishNotificationsJob, UpdateDefaultVersion, + self, CheckTyposquat, GenerateOgImage, SendPublishNotificationsJob, UpdateDefaultVersion, }; use axum::Json; use axum::body::{Body, Bytes}; @@ -549,14 +549,14 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult AppResult Date: Mon, 30 Jun 2025 18:18:09 +0200 Subject: [PATCH 5/6] crates-admin: Add command to enqueue OpenGraph image generation jobs --- src/bin/crates-admin/enqueue_job.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/bin/crates-admin/enqueue_job.rs b/src/bin/crates-admin/enqueue_job.rs index 1f85346ef2e..cc04ab334bb 100644 --- a/src/bin/crates-admin/enqueue_job.rs +++ b/src/bin/crates-admin/enqueue_job.rs @@ -34,6 +34,12 @@ pub enum Command { #[arg()] name: String, }, + /// Generate OpenGraph images for the specified crates + GenerateOgImage { + /// Crate names to generate OpenGraph images for + #[arg(required = true)] + names: Vec, + }, ProcessCdnLogQueue(jobs::ProcessCdnLogQueue), SyncAdmins { /// Force a sync even if one is already in progress @@ -143,6 +149,11 @@ pub async fn run(command: Command) -> Result<()> { jobs::CheckTyposquat::new(&name).enqueue(&mut conn).await?; } + Command::GenerateOgImage { names } => { + for name in names { + jobs::GenerateOgImage::new(name).enqueue(&mut conn).await?; + } + } Command::SendTokenExpiryNotifications => { jobs::SendTokenExpiryNotifications .enqueue(&mut conn) From d5771caac753e3490a2031a078efc371307c6395 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Mon, 30 Jun 2025 19:13:50 +0200 Subject: [PATCH 6/6] crates-admin: Add backfill tool for generating OG images for existing crates --- src/bin/crates-admin/backfill_og_images.rs | 123 +++++++++++++++++++++ src/bin/crates-admin/main.rs | 3 + 2 files changed, 126 insertions(+) create mode 100644 src/bin/crates-admin/backfill_og_images.rs diff --git a/src/bin/crates-admin/backfill_og_images.rs b/src/bin/crates-admin/backfill_og_images.rs new file mode 100644 index 00000000000..5a4c5c7d33d --- /dev/null +++ b/src/bin/crates-admin/backfill_og_images.rs @@ -0,0 +1,123 @@ +use anyhow::Result; +use crates_io::db; +use crates_io::schema::{background_jobs, crates}; +use crates_io::worker::jobs::GenerateOgImage; +use crates_io_worker::BackgroundJob; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use tracing::{info, warn}; + +#[derive(clap::Parser, Debug)] +#[command( + name = "backfill-og-images", + about = "Enqueue OG image generation jobs for existing crates" +)] +pub struct Opts { + #[arg(long, default_value = "1000")] + /// Batch size for enqueueing crates (default: 1000) + batch_size: usize, + + #[arg(long)] + /// Only generate OG images for crates with names starting with this prefix + prefix: Option, + + #[arg(long)] + /// Offset to start enqueueing from (useful for resuming) + offset: Option, +} + +pub async fn run(opts: Opts) -> Result<()> { + let mut conn = db::oneoff_connection().await?; + + info!("Starting OG image backfill with options: {opts:?}"); + + // Helper function to build query + let build_query = |offset: i64| { + let mut query = crates::table + .select(crates::name) + .order(crates::name) + .into_boxed(); + + if let Some(prefix) = &opts.prefix { + query = query.filter(crates::name.like(format!("{prefix}%"))); + } + + query.offset(offset) + }; + + // Count total crates to process + let mut count_query = crates::table.into_boxed(); + if let Some(prefix) = &opts.prefix { + count_query = count_query.filter(crates::name.like(format!("{prefix}%"))); + } + let total_crates: i64 = count_query.count().get_result(&mut conn).await?; + + info!("Total crates to enqueue: {total_crates}"); + + let mut offset = opts.offset.unwrap_or(0); + let mut enqueued = 0; + let mut errors = 0; + + loop { + // Fetch batch of crate names + let crate_names: Vec = build_query(offset) + .limit(opts.batch_size as i64) + .load(&mut conn) + .await?; + + if crate_names.is_empty() { + break; + } + + let batch_size = crate_names.len(); + info!( + "Enqueueing batch {}-{} of {total_crates}", + offset + 1, + offset + batch_size as i64 + ); + + // Create batch of jobs + let jobs = crate_names + .into_iter() + .map(GenerateOgImage::new) + .map(|job| { + Ok(( + background_jobs::job_type.eq(GenerateOgImage::JOB_NAME), + background_jobs::data.eq(serde_json::to_value(job)?), + background_jobs::priority.eq(-10), + )) + }) + .collect::>>()?; + + // Batch insert all jobs + let result = diesel::insert_into(background_jobs::table) + .values(jobs) + .execute(&mut conn) + .await; + + match result { + Ok(inserted_count) => { + enqueued += inserted_count; + info!("Enqueued {enqueued} jobs so far..."); + } + Err(e) => { + errors += batch_size; + warn!("Failed to enqueue batch of OG image jobs: {e}"); + } + } + + // Break if we've processed fewer than batch_size (last batch) + if batch_size < opts.batch_size { + break; + } + + offset += opts.batch_size as i64; + } + + info!("Jobs enqueued: {enqueued}"); + if errors > 0 { + warn!("{errors} jobs failed to enqueue. Check logs above for details."); + } + + Ok(()) +} diff --git a/src/bin/crates-admin/main.rs b/src/bin/crates-admin/main.rs index de6c1ce09f0..0e3b8cc1e1c 100644 --- a/src/bin/crates-admin/main.rs +++ b/src/bin/crates-admin/main.rs @@ -1,6 +1,7 @@ #[macro_use] extern crate tracing; +mod backfill_og_images; mod default_versions; mod delete_crate; mod delete_version; @@ -17,6 +18,7 @@ mod yank_version; #[derive(clap::Parser, Debug)] #[command(name = "crates-admin")] enum Command { + BackfillOgImages(backfill_og_images::Opts), DeleteCrate(delete_crate::Opts), DeleteVersion(delete_version::Opts), Populate(populate::Opts), @@ -46,6 +48,7 @@ async fn main() -> anyhow::Result<()> { span.record("command", tracing::field::debug(&command)); match command { + Command::BackfillOgImages(opts) => backfill_og_images::run(opts).await, Command::DeleteCrate(opts) => delete_crate::run(opts).await, Command::DeleteVersion(opts) => delete_version::run(opts).await, Command::Populate(opts) => populate::run(opts).await,