use crate::{
BlobPackLimits, BlobSource, BlobUpload, CacheDigest, RemoteActionResult, RemoteCacheClient,
};
use futures_util::future::{BoxFuture, Shared};
use futures_util::{FutureExt, StreamExt, stream};
use log::warn;
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
const MAX_UPLOAD_TRANSFERS: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UploadOutcome {
Uploaded,
Skipped,
Failed,
}
impl UploadOutcome {
fn published(self) -> bool {
matches!(self, Self::Uploaded)
}
}
pub(crate) type UploadTicket = Shared<BoxFuture<'static, UploadOutcome>>;
#[derive(Default)]
pub(crate) struct ConnectionUploads {
tickets: Vec<UploadTicket>,
}
impl ConnectionUploads {
fn record(&mut self, ticket: UploadTicket) {
self.tickets.push(ticket);
}
fn prerequisites(&self) -> Vec<UploadTicket> {
self.tickets.clone()
}
}
pub(crate) trait UploadSink: Send + Sync {
fn record_blob_uploaded(&self, bytes: u64);
fn record_action_uploaded(&self);
fn record_blob_pack_uploaded(&self, blobs: u64);
fn record_upload_failure(&self);
}
enum QueuedUpload {
Blob {
digest: CacheDigest,
path: PathBuf,
done: tokio::sync::oneshot::Sender<UploadOutcome>,
},
ActionResult {
result: RemoteActionResult,
prerequisites: Vec<UploadTicket>,
done: tokio::sync::oneshot::Sender<UploadOutcome>,
},
}
impl QueuedUpload {
fn is_blob(&self) -> bool {
matches!(self, Self::Blob { .. })
}
}
struct PackMember {
digest: CacheDigest,
path: PathBuf,
done: tokio::sync::oneshot::Sender<UploadOutcome>,
}
fn group_into_packs(
members: Vec<PackMember>,
limits: BlobPackLimits,
) -> (Vec<Vec<PackMember>>, Vec<PackMember>) {
let mut packs = Vec::new();
let mut singles = Vec::new();
let mut current: Vec<PackMember> = Vec::new();
let mut current_bytes = 0u64;
for member in members {
if member.digest.size > limits.max_bytes {
singles.push(member);
continue;
}
let would_exceed = current.len() >= limits.max_items
|| current_bytes.saturating_add(member.digest.size) > limits.max_bytes;
if would_exceed && !current.is_empty() {
close_pack(std::mem::take(&mut current), &mut packs, &mut singles);
current_bytes = 0;
}
current_bytes = current_bytes.saturating_add(member.digest.size);
current.push(member);
}
close_pack(current, &mut packs, &mut singles);
(packs, singles)
}
fn close_pack(
pack: Vec<PackMember>,
packs: &mut Vec<Vec<PackMember>>,
singles: &mut Vec<PackMember>,
) {
if pack.len() < 2 {
singles.extend(pack);
} else {
packs.push(pack);
}
}
#[derive(Clone)]
pub(crate) struct UploadQueue {
inner: Arc<Inner>,
}
struct Inner {
remote: Arc<RemoteCacheClient>,
sink: Arc<dyn UploadSink>,
transfers: Arc<tokio::sync::Semaphore>,
remote_transfers: Arc<tokio::sync::Semaphore>,
pending: Mutex<Vec<QueuedUpload>>,
blob_tickets: Mutex<BTreeMap<CacheDigest, UploadTicket>>,
action_tickets: Mutex<BTreeMap<CacheDigest, UploadTicket>>,
work: tokio::sync::Notify,
draining: AtomicBool,
worker: Mutex<Option<tokio::task::JoinHandle<()>>>,
}
impl UploadQueue {
pub(crate) fn new(
remote: Arc<RemoteCacheClient>,
sink: Arc<dyn UploadSink>,
remote_transfers: Arc<tokio::sync::Semaphore>,
) -> Self {
Self {
inner: Arc::new(Inner {
remote,
sink,
transfers: Arc::new(tokio::sync::Semaphore::new(MAX_UPLOAD_TRANSFERS)),
remote_transfers,
pending: Mutex::new(Vec::new()),
blob_tickets: Mutex::new(BTreeMap::new()),
action_tickets: Mutex::new(BTreeMap::new()),
work: tokio::sync::Notify::new(),
draining: AtomicBool::new(false),
worker: Mutex::new(None),
}),
}
}
pub(crate) fn queue_blob(
&self,
digest: &CacheDigest,
path: PathBuf,
connection: &mut ConnectionUploads,
) {
let ticket = {
let mut tickets = self.inner.blob_tickets.lock().unwrap();
match tickets
.get(digest)
.map(|ticket| (ticket.clone(), ticket.peek().copied()))
{
Some((ticket, None | Some(UploadOutcome::Uploaded))) => ticket,
_ => {
let (done, ticket) = ticket_channel();
tickets.insert(digest.clone(), ticket.clone());
self.push(QueuedUpload::Blob {
digest: digest.clone(),
path,
done,
});
ticket
}
}
};
connection.record(ticket);
}
pub(crate) fn queue_action_result(
&self,
result: &RemoteActionResult,
connection: &ConnectionUploads,
) {
let mut prerequisites = connection.prerequisites();
if prerequisites.is_empty() {
prerequisites = self
.inner
.blob_tickets
.lock()
.unwrap()
.values()
.cloned()
.collect();
}
let (done, ticket) = ticket_channel();
self.inner
.action_tickets
.lock()
.unwrap()
.insert(result.action.clone(), ticket);
self.push(QueuedUpload::ActionResult {
result: result.clone(),
prerequisites,
done,
});
}
fn push(&self, upload: QueuedUpload) {
self.inner.pending.lock().unwrap().push(upload);
self.ensure_worker();
self.inner.work.notify_one();
}
fn ensure_worker(&self) {
let mut worker = self.inner.worker.lock().unwrap();
if worker.is_some() {
return;
}
let inner = self.inner.clone();
*worker = Some(tokio::spawn(async move { inner.run().await }));
}
pub(crate) async fn wait_for_actions(&self, actions: &[CacheDigest]) -> BTreeSet<CacheDigest> {
let tickets: Vec<(CacheDigest, UploadTicket)> = {
let queued = self.inner.action_tickets.lock().unwrap();
actions
.iter()
.filter_map(|action| {
queued
.get(action)
.map(|ticket| (action.clone(), ticket.clone()))
})
.collect()
};
let mut unpublished = BTreeSet::new();
for (action, ticket) in tickets {
if !ticket.await.published() {
unpublished.insert(action);
}
}
unpublished
}
pub(crate) async fn drain(&self) {
self.inner.draining.store(true, Ordering::Release);
let worker = self.inner.worker.lock().unwrap().take();
match worker {
Some(worker) => {
self.inner.work.notify_one();
if let Err(error) = worker.await {
warn!("remote cache upload queue failed: {error}");
}
}
None => self.inner.run().await,
}
}
}
impl Inner {
async fn run(&self) {
loop {
let batch = std::mem::take(&mut *self.pending.lock().unwrap());
if batch.is_empty() {
if self.draining.load(Ordering::Acquire) {
return;
}
self.work.notified().await;
continue;
}
self.run_batch(batch).await;
}
}
async fn run_batch(&self, batch: Vec<QueuedUpload>) {
let (blobs, results): (Vec<_>, Vec<_>) = batch.into_iter().partition(QueuedUpload::is_blob);
self.run_blob_phase(blobs).await;
self.run_phase(results).await;
}
async fn run_phase(&self, uploads: Vec<QueuedUpload>) {
stream::iter(uploads)
.map(|upload| self.run_upload(upload))
.buffer_unordered(MAX_UPLOAD_TRANSFERS)
.collect::<Vec<()>>()
.await;
}
async fn run_blob_phase(&self, blobs: Vec<QueuedUpload>) {
if blobs.len() < 2 {
return self.run_phase(blobs).await;
}
let limits = self
.remote
.blob_pack_upload_limits()
.await
.unwrap_or_default();
let Some(limits) = limits else {
return self.run_phase(blobs).await;
};
let mut members = Vec::with_capacity(blobs.len());
for upload in blobs {
match upload {
QueuedUpload::Blob { digest, path, done } => {
members.push(PackMember { digest, path, done });
}
other => self.run_upload(other).await,
}
}
let (packs, singles) = group_into_packs(members, limits);
let packed = stream::iter(packs)
.map(|pack| self.upload_pack(pack))
.buffer_unordered(MAX_UPLOAD_TRANSFERS)
.collect::<Vec<()>>();
futures_util::future::join(packed, self.upload_members(singles)).await;
}
async fn upload_pack(&self, pack: Vec<PackMember>) {
let mut present = Vec::with_capacity(pack.len());
for member in pack {
if tokio::fs::try_exists(&member.path).await.unwrap_or(false) {
present.push(member);
} else {
warn!(
"remote cache blob upload skipped for {}: the local object is gone",
member.digest.hash
);
self.sink.record_upload_failure();
let _ = member.done.send(UploadOutcome::Skipped);
}
}
if present.len() < 2 {
self.upload_members(present).await;
return;
}
let uploads: Vec<BlobUpload> = present
.iter()
.map(|member| BlobUpload {
digest: member.digest.clone(),
source: BlobSource::Path(member.path.clone()),
})
.collect();
let receipt = {
let Ok(_permit) = self.transfers.acquire().await else {
return;
};
let Ok(_transfer) = self.remote_transfers.acquire().await else {
return;
};
self.remote.put_blob_pack(&uploads).await
};
match receipt {
Ok(Some(_)) => {
self.sink.record_blob_pack_uploaded(present.len() as u64);
for member in present {
self.sink.record_blob_uploaded(member.digest.size);
let _ = member.done.send(UploadOutcome::Uploaded);
}
}
Ok(None) => self.upload_members(present).await,
Err(error) => {
warn!("remote cache blob pack upload failed: {error}");
self.upload_members(present).await;
}
}
}
async fn upload_members(&self, members: Vec<PackMember>) {
stream::iter(members)
.map(|member| self.upload_member(member))
.buffer_unordered(MAX_UPLOAD_TRANSFERS)
.collect::<Vec<()>>()
.await;
}
async fn upload_member(&self, member: PackMember) {
let outcome = self.upload_blob(&member.digest, &member.path).await;
let _ = member.done.send(outcome);
}
async fn run_upload(&self, upload: QueuedUpload) {
match upload {
QueuedUpload::Blob { digest, path, done } => {
let outcome = self.upload_blob(&digest, &path).await;
let _ = done.send(outcome);
}
QueuedUpload::ActionResult {
result,
prerequisites,
done,
} => {
let outcome = self.upload_action_result(&result, prerequisites).await;
let _ = done.send(outcome);
}
}
}
async fn upload_blob(&self, digest: &CacheDigest, path: &PathBuf) -> UploadOutcome {
if !tokio::fs::try_exists(path).await.unwrap_or(false) {
warn!(
"remote cache blob upload skipped for {}: the local object is gone",
digest.hash
);
self.sink.record_upload_failure();
return UploadOutcome::Skipped;
}
let _permit = match self.transfers.acquire().await {
Ok(permit) => permit,
Err(_) => return UploadOutcome::Failed,
};
let _transfer = match self.remote_transfers.acquire().await {
Ok(permit) => permit,
Err(_) => return UploadOutcome::Failed,
};
let upload = BlobUpload {
digest: digest.clone(),
source: BlobSource::Path(path.clone()),
};
match self.remote.put_blob(&upload).await {
Ok(()) => {
self.sink.record_blob_uploaded(digest.size);
UploadOutcome::Uploaded
}
Err(error) => {
if missing_source(&error) {
warn!(
"remote cache blob upload skipped for {}: the local object is gone",
digest.hash
);
self.sink.record_upload_failure();
return UploadOutcome::Skipped;
}
warn!(
"remote cache blob upload failed for {}: {error}",
digest.hash
);
self.sink.record_upload_failure();
UploadOutcome::Failed
}
}
}
async fn upload_action_result(
&self,
result: &RemoteActionResult,
prerequisites: Vec<UploadTicket>,
) -> UploadOutcome {
for prerequisite in prerequisites {
if !prerequisite.await.published() {
warn!(
"remote cache action upload skipped for {}: a referenced blob was not published",
result.action.hash
);
return UploadOutcome::Skipped;
}
}
let _permit = match self.transfers.acquire().await {
Ok(permit) => permit,
Err(_) => return UploadOutcome::Failed,
};
let _transfer = match self.remote_transfers.acquire().await {
Ok(permit) => permit,
Err(_) => return UploadOutcome::Failed,
};
match self.remote.put_action_result(result).await {
Ok(()) => {
self.sink.record_action_uploaded();
UploadOutcome::Uploaded
}
Err(error) => {
warn!(
"remote cache action upload failed for {}: {error}",
result.action.hash
);
self.sink.record_upload_failure();
UploadOutcome::Failed
}
}
}
}
fn ticket_channel() -> (tokio::sync::oneshot::Sender<UploadOutcome>, UploadTicket) {
let (sender, receiver) = tokio::sync::oneshot::channel();
let ticket = receiver
.map(|outcome| outcome.unwrap_or(UploadOutcome::Failed))
.boxed()
.shared();
(sender, ticket)
}
fn missing_source(error: &eyre::Report) -> bool {
error.chain().any(|cause| {
cause
.downcast_ref::<std::io::Error>()
.is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound)
})
}