mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(gifs): fetch featured category previews concurrently (#3117)
This commit is contained in:
Generated
+2
@@ -1785,8 +1785,10 @@ name = "fluxer-gifs"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"axum",
|
||||||
"fluxer-svc",
|
"fluxer-svc",
|
||||||
"fluxer_common",
|
"fluxer_common",
|
||||||
|
"futures",
|
||||||
"hmac 0.13.0",
|
"hmac 0.13.0",
|
||||||
"moka",
|
"moka",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ publish = false
|
|||||||
fluxer_common = { path = "../fluxer_common" }
|
fluxer_common = { path = "../fluxer_common" }
|
||||||
anyhow = "1.0.104"
|
anyhow = "1.0.104"
|
||||||
fluxer-svc = { path = "../fluxer_svc", default-features = false }
|
fluxer-svc = { path = "../fluxer_svc", default-features = false }
|
||||||
|
futures = "0.3"
|
||||||
hmac = "0.13.0"
|
hmac = "0.13.0"
|
||||||
moka = { version = "0.12.16", features = ["future", "sync"] }
|
moka = { version = "0.12.16", features = ["future", "sync"] }
|
||||||
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls"] }
|
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls"] }
|
||||||
@@ -21,3 +22,7 @@ tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread", "signal",
|
|||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
url = "2.5"
|
url = "2.5"
|
||||||
urlencoding = "2.1"
|
urlencoding = "2.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
axum = { version = "0.8.9", default-features = false, features = ["http1", "json", "query", "tokio"] }
|
||||||
|
tokio = { version = "1.53.1", features = ["net"] }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
use crate::media_proxy::MediaProxyUrlBuilder;
|
use crate::media_proxy::MediaProxyUrlBuilder;
|
||||||
use crate::types::{GifCategoryTag, GifItem, GifMediaFormat};
|
use crate::types::{GifCategoryTag, GifItem, GifMediaFormat};
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
use futures::stream::{self, StreamExt};
|
||||||
use reqwest::Url;
|
use reqwest::Url;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -19,6 +20,7 @@ const MAX_RETRIES: usize = 3;
|
|||||||
const BACKOFF_BASE_DELAY: Duration = Duration::from_secs(1);
|
const BACKOFF_BASE_DELAY: Duration = Duration::from_secs(1);
|
||||||
const KLIPY_RESPONSE_LIMIT_BYTES: usize = 512 * 1024;
|
const KLIPY_RESPONSE_LIMIT_BYTES: usize = 512 * 1024;
|
||||||
const MAX_FEATURED_CATEGORIES: usize = 50;
|
const MAX_FEATURED_CATEGORIES: usize = 50;
|
||||||
|
const FEATURED_CATEGORY_PREVIEW_CONCURRENCY: usize = 10;
|
||||||
const FEATURED_CATEGORIES_FETCH_TIMEOUT: Duration = Duration::from_secs(30);
|
const FEATURED_CATEGORIES_FETCH_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
const FLUXER_USER_AGENT: &str = "Fluxerbot/1.0 (+https://fluxer.app)";
|
const FLUXER_USER_AGENT: &str = "Fluxerbot/1.0 (+https://fluxer.app)";
|
||||||
const KLIPY_PROVIDER_NAME: &str = "klipy";
|
const KLIPY_PROVIDER_NAME: &str = "klipy";
|
||||||
@@ -54,6 +56,7 @@ static MEDIA_FILTER: LazyLock<String> = LazyLock::new(|| MEDIA_FORMAT_PREFERENCE
|
|||||||
pub struct KlipyClient {
|
pub struct KlipyClient {
|
||||||
http_client: reqwest::Client,
|
http_client: reqwest::Client,
|
||||||
media_proxy: MediaProxyUrlBuilder,
|
media_proxy: MediaProxyUrlBuilder,
|
||||||
|
base_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -158,9 +161,16 @@ impl KlipyClient {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
http_client,
|
http_client,
|
||||||
media_proxy,
|
media_proxy,
|
||||||
|
base_url: KLIPY_BASE_URL.to_owned(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn with_base_url(mut self, base_url: &str) -> Self {
|
||||||
|
self.base_url = base_url.trim_end_matches('/').to_owned();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn search(
|
pub async fn search(
|
||||||
&self,
|
&self,
|
||||||
api_key: &str,
|
api_key: &str,
|
||||||
@@ -335,13 +345,15 @@ impl KlipyClient {
|
|||||||
.take(MAX_FEATURED_CATEGORIES)
|
.take(MAX_FEATURED_CATEGORIES)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let mut categories = Vec::with_capacity(search_terms.len());
|
let categories = stream::iter(search_terms)
|
||||||
for search_term in search_terms {
|
.map(|search_term| {
|
||||||
|
let normalized_locale = &normalized_locale;
|
||||||
|
async move {
|
||||||
let gif = match self
|
let gif = match self
|
||||||
.search(
|
.search(
|
||||||
api_key,
|
api_key,
|
||||||
&search_term,
|
&search_term,
|
||||||
&normalized_locale,
|
normalized_locale,
|
||||||
KLIPY_FEATURED_CATEGORY_REFRESH_COUNTRY,
|
KLIPY_FEATURED_CATEGORY_REFRESH_COUNTRY,
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
@@ -358,8 +370,12 @@ impl KlipyClient {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
categories.push(category_response(search_term, gif));
|
category_response(search_term, gif)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.buffered(FEATURED_CATEGORY_PREVIEW_CONCURRENCY)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(categories)
|
Ok(categories)
|
||||||
}
|
}
|
||||||
@@ -498,7 +514,7 @@ impl KlipyClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn create_url(&self, endpoint: &str, params: &[(&str, &str)]) -> anyhow::Result<Url> {
|
fn create_url(&self, endpoint: &str, params: &[(&str, &str)]) -> anyhow::Result<Url> {
|
||||||
let mut url = Url::parse(&format!("{KLIPY_BASE_URL}/{endpoint}"))?;
|
let mut url = Url::parse(&format!("{}/{endpoint}", self.base_url))?;
|
||||||
{
|
{
|
||||||
let mut query = url.query_pairs_mut();
|
let mut query = url.query_pairs_mut();
|
||||||
query.append_pair("client_key", CLIENT_KEY);
|
query.append_pair("client_key", CLIENT_KEY);
|
||||||
@@ -841,6 +857,72 @@ fn category_response(name: String, gif: Option<GifItem>) -> GifCategoryTag {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn cold_featured_categories_fit_the_router_shard_budget() {
|
||||||
|
use axum::extract::Query;
|
||||||
|
use axum::routing::get;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
const UPSTREAM_LATENCY: Duration = Duration::from_millis(200);
|
||||||
|
|
||||||
|
let categories = || async {
|
||||||
|
sleep(UPSTREAM_LATENCY).await;
|
||||||
|
let tags = (0..MAX_FEATURED_CATEGORIES)
|
||||||
|
.map(|index| serde_json::json!({"searchterm": format!("term-{index}")}))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
axum::Json(serde_json::json!({ "tags": tags }))
|
||||||
|
};
|
||||||
|
let search = |Query(params): Query<HashMap<String, String>>| async move {
|
||||||
|
sleep(UPSTREAM_LATENCY).await;
|
||||||
|
let term = params.get("q").cloned().unwrap_or_default();
|
||||||
|
axum::Json(serde_json::json!({
|
||||||
|
"results": [{
|
||||||
|
"id": term,
|
||||||
|
"title": term,
|
||||||
|
"itemurl": format!("https://klipy.com/gifs/{term}"),
|
||||||
|
"media_formats": {
|
||||||
|
"gif": {"url": format!("https://static.klipy.com/{term}.gif"), "dims": [100, 100]}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
let app = axum::Router::new()
|
||||||
|
.route("/categories", get(categories))
|
||||||
|
.route("/search", get(search));
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||||
|
.await
|
||||||
|
.expect("bind");
|
||||||
|
let address = listener.local_addr().expect("local addr");
|
||||||
|
tokio::spawn(async move { axum::serve(listener, app).await });
|
||||||
|
|
||||||
|
let client = KlipyClient::new(MediaProxyUrlBuilder::for_test(
|
||||||
|
"https://media.example.test",
|
||||||
|
"secret",
|
||||||
|
))
|
||||||
|
.expect("client")
|
||||||
|
.with_base_url(&format!("http://{address}"));
|
||||||
|
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let categories = client
|
||||||
|
.featured_categories("key", "sv-SE")
|
||||||
|
.await
|
||||||
|
.expect("featured categories");
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
|
||||||
|
assert_eq!(MAX_FEATURED_CATEGORIES, categories.len());
|
||||||
|
assert!(categories.iter().all(|category| !category.src.is_empty()));
|
||||||
|
assert_eq!("term-0", categories[0].name);
|
||||||
|
assert_eq!(
|
||||||
|
format!("term-{}", MAX_FEATURED_CATEGORIES - 1),
|
||||||
|
categories[MAX_FEATURED_CATEGORIES - 1].name
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
elapsed < fluxer_svc::router::SHARD_REQUEST_TIMEOUT / 2,
|
||||||
|
"cold featured categories took {elapsed:?}, the router gives the shard {:?}",
|
||||||
|
fluxer_svc::router::SHARD_REQUEST_TIMEOUT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn locale_uses_klipy_supported_form() {
|
fn locale_uses_klipy_supported_form() {
|
||||||
assert_eq!(normalize_locale("en-US"), "en_US");
|
assert_eq!(normalize_locale("en-US"), "en_US");
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use tokio::sync::{Semaphore, TryAcquireError};
|
|||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
pub(crate) const SHARD_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
|
pub const SHARD_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
const MAX_BROADCAST_CONCURRENCY: usize = 32;
|
const MAX_BROADCAST_CONCURRENCY: usize = 32;
|
||||||
const MAX_ROUTER_REQUEST_BYTES: usize = 2 * 1024 * 1024;
|
const MAX_ROUTER_REQUEST_BYTES: usize = 2 * 1024 * 1024;
|
||||||
const LEGACY_SHARD_DECODE_ERROR: &[u8] = br#"{"error":"shard_request_decode_error"}"#;
|
const LEGACY_SHARD_DECODE_ERROR: &[u8] = br#"{"error":"shard_request_decode_error"}"#;
|
||||||
|
|||||||
Reference in New Issue
Block a user