blob: 6bc955125f2841e531936eeecd371a32a104d2f4 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::client::credentials::CredentialsReceiver;
use crate::error::{Error, Result};
use crate::io::{FileIO, Storage};
use crate::metadata::TableBucket;
use crate::proto::{PbRemoteLogFetchInfo, PbRemoteLogSegment};
use futures::TryStreamExt;
use parking_lot::Mutex;
use std::{
cmp::{Ordering, Reverse},
collections::{BinaryHeap, HashMap},
future::Future,
io, mem,
path::{Path, PathBuf},
pin::Pin,
sync::Arc,
time::Duration,
};
#[cfg(test)]
use std::{
env,
time::{SystemTime, UNIX_EPOCH},
};
use tempfile::TempDir;
use tokio::io::AsyncWriteExt;
use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore, mpsc, oneshot};
use tokio::task::JoinSet;
/// Default maximum number of remote log segments to prefetch
/// Matches Java's CLIENT_SCANNER_REMOTE_LOG_PREFETCH_NUM (default: 4)
pub const DEFAULT_SCANNER_REMOTE_LOG_PREFETCH_NUM: usize = 4;
/// Default maximum concurrent remote log downloads
/// Matches Java's REMOTE_FILE_DOWNLOAD_THREAD_NUM (default: 3)
pub const DEFAULT_REMOTE_FILE_DOWNLOAD_THREAD_NUM: usize = 3;
/// Initial retry backoff delay (milliseconds)
/// Prevents hot-spin retry loops on persistent failures
const RETRY_BACKOFF_BASE_MS: u64 = 100;
/// Maximum retry backoff delay (milliseconds)
/// Caps exponential backoff to avoid excessive delays
const RETRY_BACKOFF_MAX_MS: u64 = 5_000;
/// Maximum number of retries before giving up
/// After this many retries, the download will fail permanently
const MAX_RETRY_COUNT: u32 = 10;
/// Calculate exponential backoff delay with jitter for retries
fn calculate_backoff_delay(retry_count: u32) -> tokio::time::Duration {
use rand::Rng;
// Exponential backoff: base * 2^retry_count
let exponential_ms = RETRY_BACKOFF_BASE_MS.saturating_mul(1 << retry_count.min(10)); // Cap exponent to prevent overflow
// Cap at maximum
let capped_ms = exponential_ms.min(RETRY_BACKOFF_MAX_MS);
// Add jitter (±25% randomness) to avoid thundering herd
let mut rng = rand::rng();
let jitter = rng.random_range(0.75..=1.25);
let final_ms = ((capped_ms as f64) * jitter) as u64;
tokio::time::Duration::from_millis(final_ms)
}
/// Result of a fetch operation containing file path and size
#[derive(Debug)]
pub struct FetchResult {
pub file_path: PathBuf,
pub file_size: usize,
}
/// Trait for fetching remote log segments (allows dependency injection for testing)
pub trait RemoteLogFetcher: Send + Sync {
fn fetch(
&self,
request: &RemoteLogDownloadRequest,
) -> Pin<Box<dyn Future<Output = Result<FetchResult>> + Send>>;
}
/// Represents a remote log segment that needs to be downloaded
#[derive(Debug, Clone)]
pub struct RemoteLogSegment {
pub segment_id: String,
pub start_offset: i64,
#[allow(dead_code)]
pub end_offset: i64,
#[allow(dead_code)]
pub size_in_bytes: i32,
pub table_bucket: TableBucket,
pub max_timestamp: i64,
}
impl RemoteLogSegment {
pub fn from_proto(segment: &PbRemoteLogSegment, table_bucket: TableBucket) -> Self {
Self {
segment_id: segment.remote_log_segment_id.clone(),
start_offset: segment.remote_log_start_offset,
end_offset: segment.remote_log_end_offset,
size_in_bytes: segment.segment_size_in_bytes,
table_bucket,
// Match Java's behavior: use -1 for missing timestamp
// (Java: CommonRpcMessageUtils.java:171-174)
max_timestamp: segment.max_timestamp.unwrap_or(-1),
}
}
/// Get the local file name for this remote log segment
pub fn local_file_name(&self) -> String {
// Format: ${remote_segment_id}_${offset_prefix}.log
let offset_prefix = format!("{:020}", self.start_offset);
format!("{}_{}.log", self.segment_id, offset_prefix)
}
}
/// Represents remote log fetch information
#[derive(Debug, Clone)]
pub struct RemoteLogFetchInfo {
pub remote_log_tablet_dir: String,
#[allow(dead_code)]
pub partition_name: Option<String>,
pub remote_log_segments: Vec<RemoteLogSegment>,
pub first_start_pos: i32,
}
impl RemoteLogFetchInfo {
pub fn from_proto(info: &PbRemoteLogFetchInfo, table_bucket: TableBucket) -> Self {
let segments = info
.remote_log_segments
.iter()
.map(|s| RemoteLogSegment::from_proto(s, table_bucket.clone()))
.collect();
Self {
remote_log_tablet_dir: info.remote_log_tablet_dir.clone(),
partition_name: info.partition_name.clone(),
remote_log_segments: segments,
first_start_pos: info.first_start_pos.unwrap_or(0),
}
}
}
/// RAII guard for prefetch permit that notifies coordinator on drop
///
/// NOTE: File deletion is now handled by FileSource::drop(), not here.
/// This ensures the file is closed before deletion
#[derive(Debug)]
pub struct PrefetchPermit {
permit: Option<OwnedSemaphorePermit>,
recycle_notify: Arc<Notify>,
}
impl PrefetchPermit {
fn new(permit: OwnedSemaphorePermit, recycle_notify: Arc<Notify>) -> Self {
Self {
permit: Some(permit),
recycle_notify,
}
}
}
impl Drop for PrefetchPermit {
fn drop(&mut self) {
// Release capacity (critical: permit must be dropped before notify)
let _ = self.permit.take(); // drops permit here
// Then wake coordinator so it can acquire the now-available permit
self.recycle_notify.notify_one();
}
}
/// Downloaded remote log file with prefetch permit
/// File remains on disk for memory efficiency; file deletion is handled by FileCleanupGuard in FileSource
#[derive(Debug)]
pub struct RemoteLogFile {
/// Path to the downloaded file on local disk
pub file_path: PathBuf,
/// Size of the file in bytes
/// Currently unused but kept for potential future use (logging, metrics, etc.)
#[allow(dead_code)]
pub file_size: usize,
/// RAII permit that releases prefetch semaphore slot and notifies coordinator when dropped
pub permit: PrefetchPermit,
}
/// Represents a request to download a remote log segment with priority ordering
#[derive(Debug)]
pub struct RemoteLogDownloadRequest {
segment: RemoteLogSegment,
remote_log_tablet_dir: String,
result_sender: oneshot::Sender<Result<RemoteLogFile>>,
retry_count: u32,
next_retry_at: Option<tokio::time::Instant>,
}
impl RemoteLogDownloadRequest {
/// Get the segment (used by test fetcher implementations)
#[cfg(test)]
pub fn segment(&self) -> &RemoteLogSegment {
&self.segment
}
}
// Total ordering for priority queue (Rust requirement: cmp==Equal implies Eq)
// Primary: Java semantics (timestamp cross-bucket, offset within-bucket)
// Tie-breakers: table_bucket fields (table_id, partition_id, bucket_id), then segment_id
impl Ord for RemoteLogDownloadRequest {
fn cmp(&self, other: &Self) -> Ordering {
if self.segment.table_bucket == other.segment.table_bucket {
// Same bucket: order by start_offset (ascending - earlier segments first)
self.segment
.start_offset
.cmp(&other.segment.start_offset)
.then_with(|| self.segment.segment_id.cmp(&other.segment.segment_id))
} else {
// Different buckets: order by max_timestamp (ascending - older segments first)
// Then by table_bucket fields for true total ordering
self.segment
.max_timestamp
.cmp(&other.segment.max_timestamp)
.then_with(|| {
self.segment
.table_bucket
.table_id()
.cmp(&other.segment.table_bucket.table_id())
})
.then_with(|| {
self.segment
.table_bucket
.partition_id()
.cmp(&other.segment.table_bucket.partition_id())
})
.then_with(|| {
self.segment
.table_bucket
.bucket_id()
.cmp(&other.segment.table_bucket.bucket_id())
})
.then_with(|| self.segment.segment_id.cmp(&other.segment.segment_id))
}
}
}
impl PartialOrd for RemoteLogDownloadRequest {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl PartialEq for RemoteLogDownloadRequest {
fn eq(&self, other: &Self) -> bool {
self.cmp(other) == Ordering::Equal
}
}
impl Eq for RemoteLogDownloadRequest {}
/// Result of a download task
enum DownloadResult {
/// Successful download - deliver result to future
Success {
result: RemoteLogFile,
result_sender: oneshot::Sender<Result<RemoteLogFile>>,
},
/// Download failed - re-queue request for retry (Java pattern)
FailedRetry { request: RemoteLogDownloadRequest },
/// Download failed permanently after max retries - fail the future
FailedPermanently {
error: Error,
result_sender: oneshot::Sender<Result<RemoteLogFile>>,
},
/// Cancelled - don't deliver, don't re-queue
Cancelled,
}
/// Production implementation of RemoteLogFetcher that downloads from actual storage
struct ProductionFetcher {
credentials_rx: CredentialsReceiver,
local_log_dir: Arc<TempDir>,
remote_log_read_concurrency: usize,
}
impl RemoteLogFetcher for ProductionFetcher {
fn fetch(
&self,
request: &RemoteLogDownloadRequest,
) -> Pin<Box<dyn Future<Output = Result<FetchResult>> + Send>> {
let mut credentials_rx = self.credentials_rx.clone();
let local_log_dir = self.local_log_dir.clone();
let remote_log_read_concurrency = self.remote_log_read_concurrency;
// Clone data needed for async operation to avoid lifetime issues
let segment = request.segment.clone();
let remote_log_tablet_dir = request.remote_log_tablet_dir.to_string();
Box::pin(async move {
let local_file_name = segment.local_file_name();
let local_file_path = local_log_dir.path().join(&local_file_name);
// Build remote path
let offset_prefix = format!("{:020}", segment.start_offset);
let remote_path = format!(
"{}/{}/{}.log",
remote_log_tablet_dir, segment.segment_id, offset_prefix
);
// Get credentials from watch channel, waiting if not yet fetched
// - None = not yet fetched, wait
// - Some(props) = fetched (may be empty if no auth needed)
let remote_fs_props = {
let maybe_props = credentials_rx.borrow().clone();
match maybe_props {
Some(props) => props,
None => {
// Credentials not yet fetched, wait for first update
log::info!("Waiting for credentials to be available...");
// If the sender side has been dropped (e.g. during shutdown),
// this will return an error. Surface that as a proper error
// instead of silently falling back to empty credentials.
if let Err(e) = credentials_rx.changed().await {
let io_err = io::Error::new(
io::ErrorKind::BrokenPipe,
format!(
"credentials manager shut down before credentials were obtained: {e}"
),
);
return Err(io_err.into());
}
// After a successful change notification, credentials should be set.
// If they are still missing, treat this as an error instead of
// defaulting to an empty map (which could break auth flows).
credentials_rx
.borrow()
.clone()
.ok_or_else(|| Error::UnexpectedError {
message: "credentials not available after watch notification"
.to_string(),
source: None,
})?
}
}
};
// Download file to disk (streaming, no memory spike)
let file_path = RemoteLogDownloader::download_file(
&remote_log_tablet_dir,
&remote_path,
&local_file_path,
&remote_fs_props,
remote_log_read_concurrency,
)
.await?;
// Get file size
let metadata = tokio::fs::metadata(&file_path).await?;
let file_size = metadata.len() as usize;
// Return file path - file stays on disk until PrefetchPermit is dropped
Ok(FetchResult {
file_path,
file_size,
})
})
}
}
/// Coordinator that owns all download state and orchestrates downloads
struct DownloadCoordinator {
download_queue: BinaryHeap<Reverse<RemoteLogDownloadRequest>>,
active_downloads: JoinSet<DownloadResult>,
in_flight: usize,
prefetch_semaphore: Arc<Semaphore>,
max_concurrent_downloads: usize,
recycle_notify: Arc<Notify>,
fetcher: Arc<dyn RemoteLogFetcher>,
}
impl DownloadCoordinator {
/// Check if we should wait for recycle notification
/// Only wait if we're blocked on permits AND have pending work
fn should_wait_for_recycle(&self) -> bool {
!self.download_queue.is_empty()
&& self.in_flight < self.max_concurrent_downloads
&& self.prefetch_semaphore.available_permits() == 0
}
/// Find the earliest retry deadline among pending requests
fn next_retry_deadline(&self) -> Option<tokio::time::Instant> {
self.download_queue
.iter()
.filter_map(|Reverse(req)| req.next_retry_at)
.min()
}
}
impl DownloadCoordinator {
/// Try to start as many downloads as possible (event-driven drain)
fn drain(&mut self) {
// Collect deferred requests (backoff not ready) to push back later
let mut deferred = Vec::new();
// Scan entire queue once to find ready requests (prevents head-of-line blocking)
// Bound to reasonable max to avoid excessive work if queue is huge
let max_scan = self.download_queue.len().min(100);
let mut scanned = 0;
while !self.download_queue.is_empty()
&& self.in_flight < self.max_concurrent_downloads
&& scanned < max_scan
{
// Try acquire prefetch permit (non-blocking)
let permit = match self.prefetch_semaphore.clone().try_acquire_owned() {
Ok(p) => p,
Err(_) => break, // No permits available
};
// Pop highest priority request
let Some(Reverse(request)) = self.download_queue.pop() else {
drop(permit);
break;
};
scanned += 1;
// Retry backoff check: defer if retry time hasn't arrived yet
if let Some(next_retry_at) = request.next_retry_at {
let now = tokio::time::Instant::now();
if next_retry_at > now {
// Not ready for retry yet - defer and continue looking for ready requests
drop(permit);
deferred.push(request);
continue; // Don't block - keep looking for ready requests
}
}
// Cancellation check: skip if sender closed
if request.result_sender.is_closed() {
drop(permit);
continue; // Try next request
}
// Clone data for the spawned task
let fetcher = self.fetcher.clone();
let recycle_notify = self.recycle_notify.clone();
// Spawn download task
self.active_downloads.spawn(async move {
spawn_download_task(request, permit, fetcher, recycle_notify).await
});
self.in_flight += 1;
}
// Push deferred requests back to queue (maintains priority order)
if !deferred.is_empty() {
for req in deferred {
self.download_queue.push(Reverse(req));
}
}
}
}
/// Spawn a download task that attempts download once
/// Matches Java's RemoteLogDownloader.java
///
/// Benefits over infinite in-place retry:
/// - Failed downloads don't block prefetch slots
/// - Other segments can make progress while one is failing
/// - Natural retry through coordinator re-picking from queue
async fn spawn_download_task(
request: RemoteLogDownloadRequest,
permit: tokio::sync::OwnedSemaphorePermit,
fetcher: Arc<dyn RemoteLogFetcher>,
recycle_notify: Arc<Notify>,
) -> DownloadResult {
// Check if receiver still alive (early cancellation check)
if request.result_sender.is_closed() {
drop(permit);
return DownloadResult::Cancelled;
}
// Try download ONCE
let download_result = fetcher.fetch(&request).await;
match download_result {
Ok(fetch_result) => {
// Success - permit will be released on drop (FileSource handles file deletion)
DownloadResult::Success {
result: RemoteLogFile {
file_path: fetch_result.file_path,
file_size: fetch_result.file_size,
permit: PrefetchPermit::new(permit, recycle_notify.clone()),
},
result_sender: request.result_sender,
}
}
Err(_e) if request.result_sender.is_closed() => {
// Receiver dropped (cancelled) - release permit, don't re-queue
drop(permit);
DownloadResult::Cancelled
}
Err(e) => {
// Download failed - check if we should retry or give up
let retry_count = request.retry_count + 1;
if retry_count > MAX_RETRY_COUNT {
// Too many retries - give up and fail the future
log::error!(
"Failed to download remote log segment {} after {} retries: {}. Giving up.",
request.segment.segment_id,
retry_count,
e
);
drop(permit); // Release immediately
DownloadResult::FailedPermanently {
error: Error::UnexpectedError {
message: format!(
"Failed to download remote log segment after {retry_count} retries: {e}"
),
source: Some(Box::new(e)),
},
result_sender: request.result_sender,
}
} else {
// Retry with exponential backoff
let backoff_delay = calculate_backoff_delay(retry_count);
let next_retry_at = tokio::time::Instant::now() + backoff_delay;
log::warn!(
"Failed to download remote log segment {}: {}. Retry {}/{} after {:?}",
request.segment.segment_id,
e,
retry_count,
MAX_RETRY_COUNT,
backoff_delay
);
drop(permit); // Release immediately - critical!
// Update retry state
let mut retry_request = request;
retry_request.retry_count = retry_count;
retry_request.next_retry_at = Some(next_retry_at);
// Re-queue request to same priority queue
// Future stays with request, NOT completed - will complete on successful retry
DownloadResult::FailedRetry {
request: retry_request,
}
}
}
}
}
/// Coordinator event loop - owns all download state and reacts to events
async fn coordinator_loop(
mut coordinator: DownloadCoordinator,
mut request_receiver: mpsc::UnboundedReceiver<RemoteLogDownloadRequest>,
) {
loop {
// Drain once at start of iteration to process ready work
coordinator.drain();
// Calculate sleep duration until next retry (if any deferred requests)
let next_retry_sleep = coordinator.next_retry_deadline().map(|deadline| {
let now = tokio::time::Instant::now();
if deadline > now {
deadline - now
} else {
tokio::time::Duration::from_millis(0) // Ready now
}
});
tokio::select! {
// Event 1: NewRequest
Some(request) = request_receiver.recv() => {
coordinator.download_queue.push(Reverse(request));
// Immediately try to start this download
continue;
}
// Event 2: DownloadFinished
Some(result) = coordinator.active_downloads.join_next() => {
coordinator.in_flight -= 1;
match result {
Ok(DownloadResult::Success { result, result_sender }) => {
// Success - deliver result to future
if !result_sender.is_closed() {
let _ = result_sender.send(Ok(result));
}
// Permit held in RemoteLogFile until consumed
}
Ok(DownloadResult::FailedRetry { request }) => {
// Re-queue immediately (don't block coordinator with sleep)
// The retry time will be checked in drain() before processing
// (Java line 177: segmentsToFetch.add(request))
// Permit already released (Java line 174)
coordinator.download_queue.push(Reverse(request));
}
Ok(DownloadResult::FailedPermanently { error, result_sender }) => {
// Permanent failure - deliver error to future
if !result_sender.is_closed() {
let _ = result_sender.send(Err(error));
}
// Permit already released
}
Ok(DownloadResult::Cancelled) => {
// Cancelled - permit already released, nothing to do
}
Err(e) => {
log::error!("Download task panicked: {e:?}");
// Permit already released via RAII
}
}
// Immediately try to start another download
continue;
}
// Event 3: Recycled (only wait when blocked on permits with pending work)
_ = coordinator.recycle_notify.notified(),
if coordinator.should_wait_for_recycle() => {
// Wake up to try draining
continue;
}
// Event 4: Retry timer - wake up when next retry is ready
_ = tokio::time::sleep(next_retry_sleep.unwrap_or(tokio::time::Duration::from_secs(3600))),
if next_retry_sleep.is_some() => {
// Wake up to retry deferred requests
continue;
}
else => break, // All channels closed AND no work pending
}
}
}
type CompletionCallback = Box<dyn Fn() + Send + Sync>;
/// Future for a remote log download request
pub struct RemoteLogDownloadFuture {
result: Arc<Mutex<Option<Result<RemoteLogFile>>>>,
completion_callbacks: Arc<Mutex<Vec<CompletionCallback>>>,
}
impl RemoteLogDownloadFuture {
pub fn new(receiver: oneshot::Receiver<Result<RemoteLogFile>>) -> Self {
let result = Arc::new(Mutex::new(None));
let result_clone = Arc::clone(&result);
let completion_callbacks: Arc<Mutex<Vec<CompletionCallback>>> =
Arc::new(Mutex::new(Vec::new()));
let callbacks_clone = Arc::clone(&completion_callbacks);
// Spawn a task to wait for the download and update result, then call callbacks
tokio::spawn(async move {
let download_result = match receiver.await {
Ok(Ok(path)) => Ok(path),
Ok(Err(e)) => Err(e),
Err(e) => Err(Error::UnexpectedError {
message: format!("Download & Read future cancelled: {e:?}"),
source: None,
}),
};
*result_clone.lock() = Some(download_result);
// Call all registered callbacks
// We need to take the callbacks to avoid holding the lock while calling them
// This also ensures that any callbacks registered after this point will be called immediately
let callbacks: Vec<CompletionCallback> = {
let mut callbacks_guard = callbacks_clone.lock();
mem::take(&mut *callbacks_guard)
};
for callback in callbacks {
callback();
}
// After calling callbacks, any new callbacks registered will see is_done() == true
// and will be called immediately in on_complete()
});
Self {
result,
completion_callbacks,
}
}
/// Register a callback to be called when download completes (similar to Java's onComplete)
pub fn on_complete<F>(&self, callback: F)
where
F: Fn() + Send + Sync + 'static,
{
// Acquire callbacks lock first to ensure atomicity of the check-and-register operation
let mut callbacks_guard = self.completion_callbacks.lock();
// Check completion status while holding the callbacks lock.
// This ensures that:
// 1. If the task completes between checking is_done() and registering the callback,
// we'll see the completion state correctly
// 2. The background task cannot clear the callbacks list while we're checking/registering
let is_done = self.result.lock().is_some();
if is_done {
// If already completed, call immediately (drop lock first to avoid deadlock)
drop(callbacks_guard);
callback();
} else {
// Register the callback while holding the callbacks lock.
// This ensures that even if the background task completes right after we check
// is_done(), it will wait for us to release the lock before taking callbacks.
// When it does take callbacks, it will see our callback in the list and execute it.
callbacks_guard.push(Box::new(callback));
// Lock is automatically released here
}
}
pub fn is_done(&self) -> bool {
self.result.lock().is_some()
}
/// Take the RemoteLogFile (including the permit) from this future
/// This should only be called when the download is complete
/// This is the correct way to consume the download - it transfers permit ownership
pub fn take_remote_log_file(&self) -> Result<RemoteLogFile> {
let mut guard = self.result.lock();
match guard.take() {
Some(Ok(remote_log_file)) => Ok(remote_log_file),
Some(Err(e)) => {
let error_msg = format!("{e}");
Err(Error::IoUnexpectedError {
message: format!("Fail to get remote log file: {error_msg}"),
source: io::Error::other(error_msg),
})
}
None => Err(Error::IoUnexpectedError {
message: "Remote log file already taken or not ready".to_string(),
source: io::Error::other("Remote log file already taken or not ready"),
}),
}
}
}
/// Downloader for remote log segment files.
///
/// # Shutdown behavior
///
/// When the downloader is dropped, the request channel closes, signaling the coordinator
/// to stop accepting new work. The coordinator will finish any in-flight downloads but
/// won't wait for completion. Pending futures will fail.
pub struct RemoteLogDownloader {
request_sender: Option<mpsc::UnboundedSender<RemoteLogDownloadRequest>>,
}
impl RemoteLogDownloader {
pub fn new(
local_log_dir: TempDir,
max_prefetch_segments: usize,
max_concurrent_downloads: usize,
remote_log_read_concurrency: usize,
credentials_rx: CredentialsReceiver,
) -> Result<Self> {
let fetcher = Arc::new(ProductionFetcher {
credentials_rx,
local_log_dir: Arc::new(local_log_dir),
remote_log_read_concurrency: remote_log_read_concurrency.max(1),
});
Self::new_with_fetcher(fetcher, max_prefetch_segments, max_concurrent_downloads)
}
/// Create a RemoteLogDownloader with a custom fetcher (for testing).
pub fn new_with_fetcher(
fetcher: Arc<dyn RemoteLogFetcher>,
max_prefetch_segments: usize,
max_concurrent_downloads: usize,
) -> Result<Self> {
let (request_sender, request_receiver) = mpsc::unbounded_channel();
let coordinator = DownloadCoordinator {
download_queue: BinaryHeap::new(),
active_downloads: JoinSet::new(),
in_flight: 0,
prefetch_semaphore: Arc::new(Semaphore::new(max_prefetch_segments)),
max_concurrent_downloads,
recycle_notify: Arc::new(Notify::new()),
fetcher,
};
// Spawn coordinator task - it will exit when request_sender is dropped
tokio::spawn(coordinator_loop(coordinator, request_receiver));
Ok(Self {
request_sender: Some(request_sender),
})
}
/// Request to fetch a remote log segment to local. This method is non-blocking.
pub fn request_remote_log(
&self,
remote_log_tablet_dir: &str,
segment: &RemoteLogSegment,
) -> RemoteLogDownloadFuture {
let (result_sender, result_receiver) = oneshot::channel();
let request = RemoteLogDownloadRequest {
segment: segment.clone(),
remote_log_tablet_dir: remote_log_tablet_dir.to_string(),
result_sender,
retry_count: 0,
next_retry_at: None,
};
// Send to coordinator (non-blocking)
if let Some(ref sender) = self.request_sender {
if sender.send(request).is_err() {
// Coordinator is gone - immediately fail the future
let (error_sender, error_receiver) = oneshot::channel();
let _ = error_sender.send(Err(Error::UnexpectedError {
message: "RemoteLogDownloader coordinator has shut down".to_string(),
source: None,
}));
return RemoteLogDownloadFuture::new(error_receiver);
}
}
RemoteLogDownloadFuture::new(result_receiver)
}
}
impl Drop for RemoteLogDownloader {
fn drop(&mut self) {
// Drop the request sender to signal coordinator shutdown.
// This causes request_receiver.recv() to return None, allowing the
// coordinator to exit gracefully after processing pending work.
// The coordinator task will finish on its own when it sees the channel closed.
drop(self.request_sender.take());
}
}
impl RemoteLogDownloader {
/// Download a file from remote storage to local using streaming read/write.
async fn download_file(
remote_log_tablet_dir: &str,
remote_path: &str,
local_path: &Path,
remote_fs_props: &HashMap<String, String>,
remote_log_read_concurrency: usize,
) -> Result<PathBuf> {
// Handle both URL (e.g., "s3://bucket/path") and local file paths
// If the path doesn't contain "://", treat it as a local file path
let remote_log_tablet_dir_url = if remote_log_tablet_dir.contains("://") {
remote_log_tablet_dir.to_string()
} else {
format!("file://{remote_log_tablet_dir}")
};
// Create FileIO from the remote log tablet dir URL to get the storage
let file_io_builder = FileIO::from_url(&remote_log_tablet_dir_url)?;
// For S3/S3A URLs, inject S3 credentials from props
let file_io_builder = if remote_log_tablet_dir.starts_with("s3://")
|| remote_log_tablet_dir.starts_with("s3a://")
|| remote_log_tablet_dir.starts_with("oss://")
{
file_io_builder.with_props(
remote_fs_props
.iter()
.map(|(k, v)| (k.as_str(), v.as_str())),
)
} else {
file_io_builder
};
// Build storage and create operator directly
let storage = Storage::build(file_io_builder)?;
let (op, relative_path) = storage.create(remote_path)?;
// Timeout for remote storage operations (30 seconds)
const REMOTE_OP_TIMEOUT: Duration = Duration::from_secs(30);
const CHUNK_SIZE: usize = 8 * 1024 * 1024; // 8MiB
Self::download_file_streaming(
&op,
relative_path,
remote_path,
local_path,
CHUNK_SIZE,
remote_log_read_concurrency,
REMOTE_OP_TIMEOUT,
)
.await?;
Ok(local_path.to_path_buf())
}
async fn download_file_streaming(
op: &opendal::Operator,
relative_path: &str,
remote_path: &str,
local_path: &Path,
chunk_size: usize,
streaming_read_concurrency: usize,
remote_op_timeout: Duration,
) -> Result<()> {
let mut local_file = tokio::fs::File::create(local_path).await?;
let reader_future = op
.reader_with(relative_path)
.chunk(chunk_size)
.concurrent(streaming_read_concurrency);
let reader = tokio::time::timeout(remote_op_timeout, reader_future)
.await
.map_err(|e| Error::IoUnexpectedError {
message: format!("Timeout creating streaming reader for {remote_path}: {e}."),
source: io::ErrorKind::TimedOut.into(),
})??;
let mut stream = tokio::time::timeout(remote_op_timeout, reader.into_bytes_stream(..))
.await
.map_err(|e| Error::IoUnexpectedError {
message: format!("Timeout creating streaming bytes stream for {remote_path}: {e}."),
source: io::ErrorKind::TimedOut.into(),
})??;
let mut chunk_count = 0u64;
while let Some(chunk) = tokio::time::timeout(remote_op_timeout, stream.try_next())
.await
.map_err(|e| Error::IoUnexpectedError {
message: format!(
"Timeout streaming chunk from remote storage: {remote_path}, exception: {e}."
),
source: io::ErrorKind::TimedOut.into(),
})??
{
chunk_count += 1;
if chunk_count <= 3 || chunk_count % 10 == 0 {
log::debug!("Remote log streaming download: chunk #{chunk_count} ({remote_path})");
}
local_file.write_all(&chunk).await?;
}
local_file.sync_all().await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
/// Helper function to create a TableBucket for testing
fn create_table_bucket(table_id: i64, bucket_id: i32) -> TableBucket {
TableBucket::new(table_id, bucket_id)
}
/// Simplified fake fetcher for testing
struct FakeFetcher {
completion_gate: Arc<Notify>,
in_flight: Arc<AtomicUsize>,
max_seen_in_flight: Arc<AtomicUsize>,
fail_count: Arc<Mutex<usize>>,
auto_complete: bool,
}
impl FakeFetcher {
fn new(fail_count: usize, auto_complete: bool) -> Self {
Self {
completion_gate: Arc::new(Notify::new()),
in_flight: Arc::new(AtomicUsize::new(0)),
max_seen_in_flight: Arc::new(AtomicUsize::new(0)),
fail_count: Arc::new(Mutex::new(fail_count)),
auto_complete,
}
}
fn max_seen_in_flight(&self) -> usize {
self.max_seen_in_flight.load(Ordering::SeqCst)
}
fn in_flight(&self) -> usize {
self.in_flight.load(Ordering::SeqCst)
}
fn release_one(&self) {
self.completion_gate.notify_one();
}
fn release_all(&self) {
self.completion_gate.notify_waiters();
}
}
impl RemoteLogFetcher for FakeFetcher {
fn fetch(
&self,
request: &RemoteLogDownloadRequest,
) -> Pin<Box<dyn Future<Output = Result<FetchResult>> + Send>> {
let gate = self.completion_gate.clone();
let in_flight = self.in_flight.clone();
let max_seen = self.max_seen_in_flight.clone();
let fail_count = self.fail_count.clone();
let segment_id = request.segment().segment_id.clone();
let auto_complete = self.auto_complete;
Box::pin(async move {
// Track in-flight
let current = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
max_seen.fetch_max(current, Ordering::SeqCst);
// Wait for gate (or auto-complete)
if !auto_complete {
gate.notified().await;
} else {
tokio::task::yield_now().await;
}
// Check if should fail
let should_fail = {
let mut count = fail_count.lock();
if *count > 0 {
*count -= 1;
true
} else {
false
}
};
in_flight.fetch_sub(1, Ordering::SeqCst);
if should_fail {
Err(Error::UnexpectedError {
message: format!("Fake fetch failed for {segment_id}"),
source: None,
})
} else {
let fake_data = vec![1, 2, 3, 4];
let temp_dir = env::temp_dir();
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let file_path =
temp_dir.join(format!("fake_segment_{segment_id}_{timestamp}.log"));
tokio::fs::write(&file_path, &fake_data).await?;
Ok(FetchResult {
file_path,
file_size: fake_data.len(),
})
}
})
}
}
/// Helper function to create a RemoteLogSegment for testing
fn create_segment(
segment_id: &str,
start_offset: i64,
max_timestamp: i64,
table_bucket: TableBucket,
) -> RemoteLogSegment {
RemoteLogSegment {
segment_id: segment_id.to_string(),
start_offset,
end_offset: start_offset + 1000,
size_in_bytes: 1024,
table_bucket,
max_timestamp,
}
}
/// Helper function to create a RemoteLogDownloadRequest for testing
fn create_request(segment: RemoteLogSegment) -> RemoteLogDownloadRequest {
let (result_sender, _) = oneshot::channel();
RemoteLogDownloadRequest {
remote_log_tablet_dir: "test_dir".to_string(),
segment,
result_sender,
retry_count: 0,
next_retry_at: None,
}
}
#[test]
fn test_priority_ordering_matching_java_test_case() {
// Test priority ordering: timestamp across buckets, offset within bucket
// Does NOT test tie-breakers (segment_id) - those are implementation details
let bucket1 = create_table_bucket(1, 0);
let bucket2 = create_table_bucket(1, 1);
let bucket3 = create_table_bucket(1, 2);
let bucket4 = create_table_bucket(1, 3);
// Create segments with distinct timestamps/offsets (no ties)
let seg_negative = create_segment("seg_neg", 0, -1, bucket1.clone());
let seg_zero = create_segment("seg_zero", 0, 0, bucket2.clone());
let seg_1000 = create_segment("seg_1000", 0, 1000, bucket3.clone());
let seg_2000 = create_segment("seg_2000", 0, 2000, bucket4.clone());
let seg_same_bucket_100 = create_segment("seg_sb_100", 100, 5000, bucket1.clone());
let seg_same_bucket_50 = create_segment("seg_sb_50", 50, 5000, bucket1.clone());
let mut heap = BinaryHeap::new();
heap.push(Reverse(create_request(seg_2000)));
heap.push(Reverse(create_request(seg_same_bucket_100)));
heap.push(Reverse(create_request(seg_1000)));
heap.push(Reverse(create_request(seg_zero)));
heap.push(Reverse(create_request(seg_negative)));
heap.push(Reverse(create_request(seg_same_bucket_50)));
// Verify ordering by timestamp/offset, not segment_id
let first = heap.pop().unwrap().0;
assert_eq!(first.segment.max_timestamp, -1, "Lowest timestamp first");
let second = heap.pop().unwrap().0;
assert_eq!(second.segment.max_timestamp, 0);
let third = heap.pop().unwrap().0;
assert_eq!(third.segment.max_timestamp, 1000);
let fourth = heap.pop().unwrap().0;
assert_eq!(fourth.segment.max_timestamp, 2000);
// Last two are same bucket (ts=5000), ordered by offset
let fifth = heap.pop().unwrap().0;
assert_eq!(fifth.segment.max_timestamp, 5000);
assert_eq!(
fifth.segment.start_offset, 50,
"Lower offset first within bucket"
);
let sixth = heap.pop().unwrap().0;
assert_eq!(sixth.segment.max_timestamp, 5000);
assert_eq!(sixth.segment.start_offset, 100);
}
#[tokio::test]
async fn test_concurrency_and_priority() {
// Test concurrency limiting and priority-based scheduling together
let fake_fetcher = Arc::new(FakeFetcher::new(0, false)); // Manual control
let downloader = RemoteLogDownloader::new_with_fetcher(
fake_fetcher.clone(),
10, // High prefetch limit
2, // Max concurrent downloads = 2
)
.unwrap();
let bucket = create_table_bucket(1, 0);
// Request 4 segments with same priority (to isolate concurrency limiting from priority)
let segs: Vec<_> = (0..4)
.map(|i| create_segment(&format!("seg{i}"), i * 100, 1000, bucket.clone()))
.collect();
let _futures: Vec<_> = segs
.iter()
.map(|seg| downloader.request_remote_log("dir", seg))
.collect();
// Wait for exactly 2 to start
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(
fake_fetcher.in_flight(),
2,
"Concurrency limit: exactly 2 should be in-flight"
);
// Release one
fake_fetcher.release_one();
tokio::time::sleep(Duration::from_millis(50)).await;
// Max should never exceed 2
assert_eq!(
fake_fetcher.max_seen_in_flight(),
2,
"Max concurrent should not exceed 2"
);
// Release all
fake_fetcher.release_all();
}
#[tokio::test]
async fn test_prefetch_limit() {
// Test that prefetch semaphore limits outstanding downloads
let fake_fetcher = Arc::new(FakeFetcher::new(0, true)); // Auto-complete
let downloader = RemoteLogDownloader::new_with_fetcher(
fake_fetcher,
2, // Max prefetch = 2
10, // High concurrent limit
)
.unwrap();
let bucket = create_table_bucket(1, 0);
// Request 4 downloads
let segs: Vec<_> = (0..4)
.map(|i| create_segment(&format!("seg{i}"), i * 100, 1000, bucket.clone()))
.collect();
let mut futures: Vec<_> = segs
.iter()
.map(|seg| downloader.request_remote_log("dir", seg))
.collect();
// Wait for first 2 to complete
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
loop {
if futures.iter().filter(|f| f.is_done()).count() >= 2 {
break;
}
if tokio::time::Instant::now() > deadline {
panic!("Timeout waiting for first 2 downloads");
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
// Verify 3rd and 4th are blocked (prefetch limit)
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(
futures.iter().filter(|f| f.is_done()).count(),
2,
"Prefetch limit: only 2 should complete"
);
// Drop first 2 (releases permits)
let f4 = futures.pop().unwrap();
let f3 = futures.pop().unwrap();
drop(futures);
// 3rd and 4th should now complete
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
loop {
if f3.is_done() && f4.is_done() {
break;
}
if tokio::time::Instant::now() > deadline {
panic!("Timeout after permit release");
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
#[tokio::test]
async fn test_retry_and_cancellation() {
// Test retry with exponential backoff
let fake_fetcher = Arc::new(FakeFetcher::new(2, true)); // Fail twice, succeed third time
let downloader =
RemoteLogDownloader::new_with_fetcher(fake_fetcher.clone(), 10, 1).unwrap();
let bucket = create_table_bucket(1, 0);
let seg = create_segment("seg1", 0, 1000, bucket);
let future = downloader.request_remote_log("dir", &seg);
// Should succeed after retries
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
if future.is_done() {
break;
}
if tokio::time::Instant::now() > deadline {
panic!("Timeout waiting for retry to succeed");
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(future.is_done(), "Should succeed after retries");
// Test cancellation
let seg2 = create_segment("seg2", 100, 1000, create_table_bucket(1, 0));
let fake_fetcher2 = Arc::new(FakeFetcher::new(100, true)); // Fail forever
let downloader2 =
RemoteLogDownloader::new_with_fetcher(fake_fetcher2.clone(), 10, 1).unwrap();
let future2 = downloader2.request_remote_log("dir", &seg2);
tokio::time::sleep(Duration::from_millis(50)).await;
// Drop to cancel
drop(future2);
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(
fake_fetcher2.in_flight(),
0,
"Cancellation should release resources"
);
}
}