use std::{
collections::HashMap,
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
},
time::Duration,
};
use tokio::sync::{mpsc, OnceCell};
use tokio::task::JoinSet;
use crate::client;
use crate::spool;
use crate::types::HttpVersion;
static REMOTE_DUMP_TX: OnceCell<mpsc::Sender<DumpJob>> = OnceCell::const_new();
static QUEUE_BYTES: AtomicUsize = AtomicUsize::new(0);
#[inline]
fn add_queue_bytes(n: usize) {
let now = QUEUE_BYTES
.fetch_add(n, Ordering::Relaxed)
.saturating_add(n);
crate::metrics::queue_bytes(now);
}
#[inline]
fn release_queue_bytes(n: usize) {
let prev = QUEUE_BYTES
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |cur| {
Some(cur.saturating_sub(n))
})
.unwrap_or(0);
crate::metrics::queue_bytes(prev.saturating_sub(n));
}
static SKIP_BROWSER_DUMPS_ENABLED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
static SPOOL_ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
#[inline]
pub fn set_skip_browser_dumps_enabled(enabled: bool) {
SKIP_BROWSER_DUMPS_ENABLED.store(enabled, std::sync::atomic::Ordering::Relaxed);
}
#[inline]
pub fn skip_browser_dumps_enabled() -> bool {
SKIP_BROWSER_DUMPS_ENABLED.load(std::sync::atomic::Ordering::Relaxed)
}
#[inline]
pub fn set_spool_enabled(enabled: bool) {
SPOOL_ENABLED.store(enabled, std::sync::atomic::Ordering::Relaxed);
}
#[inline]
pub fn spool_enabled() -> bool {
SPOOL_ENABLED.load(std::sync::atomic::Ordering::Relaxed)
}
const SPOOL_POLL_INTERVAL: Duration = Duration::from_secs(5);
const SPOOL_DRAIN_BATCH: usize = 32;
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct DumpJob {
pub cache_key: String,
pub cache_site: String,
pub url: String,
pub method: String,
pub status: u16,
pub request_headers: HashMap<String, String>,
pub response_headers: HashMap<String, String>,
pub body: Vec<u8>,
pub http_version: HttpVersion,
pub dump_remote: Option<String>,
}
impl DumpJob {
#[inline]
pub fn estimated_bytes(&self) -> usize {
self.body.len()
+ self.cache_key.len()
+ self.cache_site.len()
+ self.url.len()
+ self.method.len()
+ 256 }
}
const MAX_BATCH_SIZE: usize = 16;
async fn init_inner(queue_cap: usize, qps: u32, timeout_ms: u64) -> mpsc::Sender<DumpJob> {
let (tx, mut rx) = mpsc::channel::<DumpJob>(queue_cap.max(1));
let drain_tx = tx.clone();
let max_concurrent = default_max_concurrent();
tokio::spawn(async move {
let sem = Arc::new(tokio::sync::Semaphore::new(max_concurrent));
let inflight: Arc<dashmap::DashSet<String>> = Arc::new(dashmap::DashSet::new());
let mut join_set = JoinSet::<Vec<String>>::new();
let qps = qps.max(1);
let spawn_interval = Duration::from_micros(1_000_000 / qps as u64);
let mut last_spawn = tokio::time::Instant::now() - spawn_interval;
let timeout = Duration::from_millis(timeout_ms);
let max_drain: usize = (queue_cap / 4).clamp(64, 1024);
loop {
reap_completed(&mut join_set, &inflight);
let spool_on = spool_enabled();
if spool_on && spool::spool_bytes() > 0 && queue_has_headroom_for_drain() {
for _ in 0..SPOOL_DRAIN_BATCH {
match spool::spool_pop_one().await {
Some(job) => {
let est = job.estimated_bytes();
add_queue_bytes(est);
match drain_tx.try_send(job) {
Ok(()) => {}
Err(mpsc::error::TrySendError::Full(returned)) => {
release_queue_bytes(est);
if let Err(err) = spool::spool_write(&returned).await {
crate::metrics::dropped(
crate::metrics::reason::SPOOL_ERROR,
);
tracing::warn!(
"remote dump: re-spool failed after drain refusal: {err}"
);
}
break;
}
Err(mpsc::error::TrySendError::Closed(_)) => {
release_queue_bytes(est);
return;
}
}
}
None => break,
}
}
}
let first_job = if spool_on {
match tokio::time::timeout(SPOOL_POLL_INTERVAL, rx.recv()).await {
Ok(Some(job)) => job,
Ok(None) => break,
Err(_) => continue,
}
} else {
match rx.recv().await {
Some(job) => job,
None => break,
}
};
let mut batch = Vec::with_capacity(max_drain.min(64));
batch.push(first_job);
while batch.len() < max_drain {
match rx.try_recv() {
Ok(job) => batch.push(job),
Err(_) => break,
}
}
let mut groups: HashMap<(String, String), Vec<DumpJob>> = HashMap::new();
for job in batch {
if !inflight.insert(job.cache_key.clone()) {
release_queue_bytes(job.estimated_bytes());
crate::metrics::dropped(crate::metrics::reason::INFLIGHT_DUP);
continue;
}
let base_url = client::resolve_base_url(job.dump_remote.as_deref()).to_string();
groups
.entry((base_url, job.cache_site.clone()))
.or_default()
.push(job);
}
let mut groups = groups.into_iter();
'groups: while let Some(((base_url, cache_site), jobs)) = groups.next() {
let mut chunks = chunks_into_vec(jobs, MAX_BATCH_SIZE).into_iter();
while let Some(chunk) = chunks.next() {
let elapsed = last_spawn.elapsed();
if elapsed < spawn_interval {
tokio::time::sleep(spawn_interval - elapsed).await;
}
last_spawn = tokio::time::Instant::now();
let permit = match sem.clone().acquire_owned().await {
Ok(p) => p,
Err(_) => {
let rest = std::iter::once(chunk)
.chain(chunks)
.flatten()
.chain(groups.flat_map(|(_, jobs)| jobs));
for job in rest {
inflight.remove(&job.cache_key);
release_queue_bytes(job.estimated_bytes());
}
break 'groups;
}
};
let inflight_ref = inflight.clone();
let base_url = base_url.clone();
let cache_site = cache_site.clone();
let keys: Vec<String> = chunk.iter().map(|j| j.cache_key.clone()).collect();
let chunk_bytes: usize = chunk.iter().map(DumpJob::estimated_bytes).sum();
join_set.spawn(async move {
let _permit = permit;
let started = std::time::Instant::now();
let result = tokio::time::timeout(
timeout,
upload_chunk(chunk, &cache_site, &base_url),
)
.await;
if result.is_err() {
crate::metrics::upload(
crate::metrics::outcome::TIMEOUT,
started.elapsed().as_secs_f64() * 1000.0,
);
tracing::warn!(
"remote cache dump: batch of {} timed out after {}ms",
keys.len(),
timeout.as_millis(),
);
}
release_queue_bytes(chunk_bytes);
for key in &keys {
inflight_ref.remove(key);
}
keys
});
}
}
reap_completed(&mut join_set, &inflight);
}
QUEUE_BYTES.store(0, Ordering::Relaxed);
while let Some(result) = join_set.join_next().await {
if let Ok(keys) = result {
for key in keys {
inflight.remove(&key);
}
}
}
});
tx
}
fn reap_completed(join_set: &mut JoinSet<Vec<String>>, inflight: &Arc<dashmap::DashSet<String>>) {
while let Some(result) = join_set.try_join_next() {
if let Ok(keys) = result {
for key in keys {
inflight.remove(&key);
}
}
}
}
async fn upload_chunk(jobs: Vec<DumpJob>, cache_site: &str, base_url: &str) {
if jobs.len() == 1 {
let job = jobs.into_iter().next().unwrap();
client::dump_to_remote(
&job.cache_key,
&job.cache_site,
&job.url,
&job.body,
&job.method,
job.status,
&job.request_headers,
&job.response_headers,
&job.http_version,
job.dump_remote.as_deref(),
)
.await;
} else {
let payloads: Vec<_> = jobs
.iter()
.map(|job| {
client::build_payload(
&job.cache_key,
&job.url,
&job.body,
&job.method,
job.status,
&job.request_headers,
&job.response_headers,
&job.http_version,
)
})
.collect();
client::dump_site_batch_to_remote(payloads, cache_site, base_url).await;
}
}
fn chunks_into_vec<T>(vec: Vec<T>, chunk_size: usize) -> Vec<Vec<T>> {
let mut result = Vec::new();
let mut current = Vec::with_capacity(chunk_size);
for item in vec {
current.push(item);
if current.len() >= chunk_size {
result.push(std::mem::replace(
&mut current,
Vec::with_capacity(chunk_size),
));
}
}
if !current.is_empty() {
result.push(current);
}
result
}
pub async fn init_remote_dump_worker(
queue_cap: usize,
qps: u32,
timeout_ms: u64,
) -> mpsc::Sender<DumpJob> {
REMOTE_DUMP_TX
.get_or_init(|| init_inner(queue_cap, qps, timeout_ms))
.await
.clone()
}
#[inline]
fn body_within_size(job: &DumpJob) -> bool {
let max = default_max_body_size();
if max > 0 && job.body.len() > max {
crate::metrics::dropped(crate::metrics::reason::OVERSIZE);
tracing::debug!(
"remote dump dropped: body {} bytes exceeds max {} for {}",
job.body.len(),
max,
job.cache_key,
);
return false;
}
true
}
#[inline]
fn queue_has_memory_headroom() -> bool {
let budget = default_queue_memory_budget();
budget == 0 || QUEUE_BYTES.load(Ordering::Relaxed) <= budget
}
#[inline]
fn queue_has_headroom_for_drain() -> bool {
let budget = default_queue_memory_budget();
budget == 0 || QUEUE_BYTES.load(Ordering::Relaxed) < budget / 2
}
static SPOOL_WRITERS: std::sync::OnceLock<Arc<tokio::sync::Semaphore>> = std::sync::OnceLock::new();
fn spool_writers() -> &'static Arc<tokio::sync::Semaphore> {
SPOOL_WRITERS.get_or_init(|| Arc::new(tokio::sync::Semaphore::new(default_spool_writers())))
}
pub fn default_spool_writers() -> usize {
std::env::var("HYBRID_CACHE_REMOTE_SPOOL_WRITERS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(16)
.max(1)
}
fn spool_fire_and_forget(job: DumpJob, overflow_reason: &'static str) -> bool {
if !spool_enabled() {
crate::metrics::dropped(overflow_reason);
return false;
}
if REMOTE_DUMP_TX.get().is_none() {
crate::metrics::dropped(crate::metrics::reason::NO_WORKER);
tracing::debug!("remote dump dropped: no worker to drain spool");
return false;
}
if tokio::runtime::Handle::try_current().is_err() {
crate::metrics::dropped(crate::metrics::reason::NO_RUNTIME);
tracing::debug!("remote dump dropped: no tokio runtime for spool write");
return false;
}
let permit = match spool_writers().clone().try_acquire_owned() {
Ok(p) => p,
Err(_) => {
crate::metrics::dropped(crate::metrics::reason::SPOOL_BUSY);
tracing::debug!(
"remote dump dropped: all spool writers busy for {}",
job.cache_key
);
return false;
}
};
tokio::spawn(async move {
let _permit = permit;
match spool::spool_write(&job).await {
Ok(n) => {
tracing::debug!(
"remote dump spooled to disk: {n} bytes for {}",
job.cache_key
);
}
Err(err) => {
crate::metrics::dropped(crate::metrics::reason::SPOOL_ERROR);
tracing::warn!(
"remote dump: spool failed for {} ({}B body): {err}",
job.cache_key,
job.body.len(),
);
}
}
});
true
}
pub async fn enqueue_best_effort(job: DumpJob) -> bool {
if !body_within_size(&job) {
return false;
}
let tx =
init_remote_dump_worker(default_queue_cap(), default_qps(), default_timeout_ms()).await;
if !queue_has_memory_headroom() {
return spool_fire_and_forget(job, crate::metrics::reason::MEMORY_BUDGET);
}
let est = job.estimated_bytes();
add_queue_bytes(est);
match tx.try_send(job) {
Ok(()) => true,
Err(mpsc::error::TrySendError::Full(returned)) => {
release_queue_bytes(est);
spool_fire_and_forget(returned, crate::metrics::reason::QUEUE_FULL)
}
Err(mpsc::error::TrySendError::Closed(_)) => {
release_queue_bytes(est);
crate::metrics::dropped(crate::metrics::reason::CLOSED);
false
}
}
}
pub async fn enqueue(job: DumpJob) -> Result<(), mpsc::error::SendError<DumpJob>> {
if !body_within_size(&job) {
return Ok(());
}
let tx =
init_remote_dump_worker(default_queue_cap(), default_qps(), default_timeout_ms()).await;
let est = job.estimated_bytes();
add_queue_bytes(est);
match tx.send(job).await {
Ok(()) => Ok(()),
Err(e) => {
release_queue_bytes(est);
crate::metrics::dropped(crate::metrics::reason::CLOSED);
Err(e)
}
}
}
pub fn worker_inited() -> bool {
REMOTE_DUMP_TX.initialized()
}
pub fn try_enqueue(job: DumpJob) -> bool {
if !body_within_size(&job) {
return false;
}
if queue_has_memory_headroom() {
if let Some(tx) = REMOTE_DUMP_TX.get() {
let est = job.estimated_bytes();
add_queue_bytes(est);
match tx.try_send(job) {
Ok(()) => return true,
Err(mpsc::error::TrySendError::Full(returned)) => {
release_queue_bytes(est);
return spool_fire_and_forget(returned, crate::metrics::reason::QUEUE_FULL);
}
Err(mpsc::error::TrySendError::Closed(_)) => {
release_queue_bytes(est);
crate::metrics::dropped(crate::metrics::reason::CLOSED);
return false;
}
}
}
crate::metrics::dropped(crate::metrics::reason::NO_WORKER);
return false; }
spool_fire_and_forget(job, crate::metrics::reason::MEMORY_BUDGET)
}
pub fn queue_bytes() -> usize {
QUEUE_BYTES.load(Ordering::Relaxed)
}
pub async fn init_default_worker() {
init_remote_dump_worker(default_queue_cap(), default_qps(), default_timeout_ms()).await;
}
pub fn default_queue_cap() -> usize {
std::env::var("HYBRID_CACHE_REMOTE_QUEUE_CAP")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2_000)
}
pub fn default_qps() -> u32 {
std::env::var("HYBRID_CACHE_REMOTE_QPS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(50)
}
pub fn default_timeout_ms() -> u64 {
std::env::var("HYBRID_CACHE_REMOTE_TIMEOUT_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2_500)
}
pub fn default_max_concurrent() -> usize {
std::env::var("HYBRID_CACHE_REMOTE_MAX_CONCURRENT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(8)
}
pub fn default_max_body_size() -> usize {
std::env::var("HYBRID_CACHE_REMOTE_MAX_BODY_SIZE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5 * 1024 * 1024)
}
pub fn default_queue_memory_budget() -> usize {
std::env::var("HYBRID_CACHE_REMOTE_MEMORY_BUDGET")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(256 * 1024 * 1024)
}