feat(actix-files): opt-in filesize threshold for faster synchronous reads (#3706)

Co-authored-by: Rob Ede <robjtede@icloud.com>
This commit is contained in:
Andrew Scott 2025-08-29 13:52:34 -07:00 committed by GitHub
parent 3c2907da41
commit 00b0f8f700
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 70 additions and 29 deletions

View File

@ -2,6 +2,7 @@
## Unreleased ## Unreleased
- Opt-In filesize threshold for faster synchronus reads that allow for 20x better performance.
- Minimum supported Rust version (MSRV) is now 1.75. - Minimum supported Rust version (MSRV) is now 1.75.
## 0.6.6 ## 0.6.6

View File

@ -24,6 +24,7 @@ pin_project! {
state: ChunkedReadFileState<Fut>, state: ChunkedReadFileState<Fut>,
counter: u64, counter: u64,
callback: F, callback: F,
read_sync: bool,
} }
} }
@ -57,6 +58,7 @@ pub(crate) fn new_chunked_read(
size: u64, size: u64,
offset: u64, offset: u64,
file: File, file: File,
size_threshold: u64,
) -> impl Stream<Item = Result<Bytes, Error>> { ) -> impl Stream<Item = Result<Bytes, Error>> {
ChunkedReadFile { ChunkedReadFile {
size, size,
@ -69,18 +71,18 @@ pub(crate) fn new_chunked_read(
}, },
counter: 0, counter: 0,
callback: chunked_read_file_callback, callback: chunked_read_file_callback,
read_sync: size < size_threshold,
} }
} }
#[cfg(not(feature = "experimental-io-uring"))] #[cfg(not(feature = "experimental-io-uring"))]
async fn chunked_read_file_callback( fn chunked_read_file_callback_sync(
mut file: File, mut file: File,
offset: u64, offset: u64,
max_bytes: usize, max_bytes: usize,
) -> Result<(File, Bytes), Error> { ) -> Result<(File, Bytes), io::Error> {
use io::{Read as _, Seek as _}; use io::{Read as _, Seek as _};
let res = actix_web::web::block(move || {
let mut buf = Vec::with_capacity(max_bytes); let mut buf = Vec::with_capacity(max_bytes);
file.seek(io::SeekFrom::Start(offset))?; file.seek(io::SeekFrom::Start(offset))?;
@ -92,8 +94,22 @@ async fn chunked_read_file_callback(
} else { } else {
Ok((file, Bytes::from(buf))) Ok((file, Bytes::from(buf)))
} }
}) }
.await??;
#[cfg(not(feature = "experimental-io-uring"))]
#[inline]
async fn chunked_read_file_callback(
file: File,
offset: u64,
max_bytes: usize,
read_sync: bool,
) -> Result<(File, Bytes), Error> {
let res = if read_sync {
chunked_read_file_callback_sync(file, offset, max_bytes)?
} else {
actix_web::web::block(move || chunked_read_file_callback_sync(file, offset, max_bytes))
.await??
};
Ok(res) Ok(res)
} }
@ -171,7 +187,7 @@ where
#[cfg(not(feature = "experimental-io-uring"))] #[cfg(not(feature = "experimental-io-uring"))]
impl<F, Fut> Stream for ChunkedReadFile<F, Fut> impl<F, Fut> Stream for ChunkedReadFile<F, Fut>
where where
F: Fn(File, u64, usize) -> Fut, F: Fn(File, u64, usize, bool) -> Fut,
Fut: Future<Output = Result<(File, Bytes), Error>>, Fut: Future<Output = Result<(File, Bytes), Error>>,
{ {
type Item = Result<Bytes, Error>; type Item = Result<Bytes, Error>;
@ -193,7 +209,7 @@ where
.take() .take()
.expect("ChunkedReadFile polled after completion"); .expect("ChunkedReadFile polled after completion");
let fut = (this.callback)(file, offset, max_bytes); let fut = (this.callback)(file, offset, max_bytes, *this.read_sync);
this.state this.state
.project_replace(ChunkedReadFileState::Future { fut }); .project_replace(ChunkedReadFileState::Future { fut });

View File

@ -49,6 +49,7 @@ pub struct Files {
use_guards: Option<Rc<dyn Guard>>, use_guards: Option<Rc<dyn Guard>>,
guards: Vec<Rc<dyn Guard>>, guards: Vec<Rc<dyn Guard>>,
hidden_files: bool, hidden_files: bool,
size_threshold: u64,
} }
impl fmt::Debug for Files { impl fmt::Debug for Files {
@ -73,6 +74,7 @@ impl Clone for Files {
use_guards: self.use_guards.clone(), use_guards: self.use_guards.clone(),
guards: self.guards.clone(), guards: self.guards.clone(),
hidden_files: self.hidden_files, hidden_files: self.hidden_files,
size_threshold: self.size_threshold,
} }
} }
} }
@ -119,6 +121,7 @@ impl Files {
use_guards: None, use_guards: None,
guards: Vec::new(), guards: Vec::new(),
hidden_files: false, hidden_files: false,
size_threshold: 0,
} }
} }
@ -204,6 +207,18 @@ impl Files {
self self
} }
/// Sets the async file-size threshold.
///
/// When a file is larger than the threshold, the reader
/// will switch from faster blocking file-reads to slower async reads
/// to avoid blocking the main-thread when processing large files.
///
/// Default is 0, meaning all files are read asyncly.
pub fn set_size_threshold(mut self, size: u64) -> Self {
self.size_threshold = size;
self
}
/// Specifies whether to use ETag or not. /// Specifies whether to use ETag or not.
/// ///
/// Default is true. /// Default is true.
@ -367,6 +382,7 @@ impl ServiceFactory<ServiceRequest> for Files {
file_flags: self.file_flags, file_flags: self.file_flags,
guards: self.use_guards.clone(), guards: self.use_guards.clone(),
hidden_files: self.hidden_files, hidden_files: self.hidden_files,
size_threshold: self.size_threshold,
}; };
if let Some(ref default) = *self.default.borrow() { if let Some(ref default) = *self.default.borrow() {

View File

@ -80,6 +80,7 @@ pub struct NamedFile {
pub(crate) content_type: Mime, pub(crate) content_type: Mime,
pub(crate) content_disposition: ContentDisposition, pub(crate) content_disposition: ContentDisposition,
pub(crate) encoding: Option<ContentEncoding>, pub(crate) encoding: Option<ContentEncoding>,
pub(crate) size_threshold: u64,
} }
#[cfg(not(feature = "experimental-io-uring"))] #[cfg(not(feature = "experimental-io-uring"))]
@ -200,6 +201,7 @@ impl NamedFile {
encoding, encoding,
status_code: StatusCode::OK, status_code: StatusCode::OK,
flags: Flags::default(), flags: Flags::default(),
size_threshold: 0,
}) })
} }
@ -353,6 +355,18 @@ impl NamedFile {
self self
} }
/// Sets the async file-size threshold.
///
/// When a file is larger than the threshold, the reader
/// will switch from faster blocking file-reads to slower async reads
/// to avoid blocking the main-thread when processing large files.
///
/// Default is 0, meaning all files are read asyncly.
pub fn set_size_threshold(mut self, size: u64) -> Self {
self.size_threshold = size;
self
}
/// Specifies whether to return `ETag` header in response. /// Specifies whether to return `ETag` header in response.
/// ///
/// Default is true. /// Default is true.
@ -440,7 +454,8 @@ impl NamedFile {
res.insert_header((header::CONTENT_ENCODING, current_encoding.as_str())); res.insert_header((header::CONTENT_ENCODING, current_encoding.as_str()));
} }
let reader = chunked::new_chunked_read(self.md.len(), 0, self.file); let reader =
chunked::new_chunked_read(self.md.len(), 0, self.file, self.size_threshold);
return res.streaming(reader); return res.streaming(reader);
} }
@ -577,7 +592,7 @@ impl NamedFile {
.map_into_boxed_body(); .map_into_boxed_body();
} }
let reader = chunked::new_chunked_read(length, offset, self.file); let reader = chunked::new_chunked_read(length, offset, self.file, self.size_threshold);
if offset != 0 || length != self.md.len() { if offset != 0 || length != self.md.len() {
res.status(StatusCode::PARTIAL_CONTENT); res.status(StatusCode::PARTIAL_CONTENT);

View File

@ -39,6 +39,7 @@ pub struct FilesServiceInner {
pub(crate) file_flags: named::Flags, pub(crate) file_flags: named::Flags,
pub(crate) guards: Option<Rc<dyn Guard>>, pub(crate) guards: Option<Rc<dyn Guard>>,
pub(crate) hidden_files: bool, pub(crate) hidden_files: bool,
pub(crate) size_threshold: u64,
} }
impl fmt::Debug for FilesServiceInner { impl fmt::Debug for FilesServiceInner {
@ -70,7 +71,9 @@ impl FilesService {
named_file.flags = self.file_flags; named_file.flags = self.file_flags;
let (req, _) = req.into_parts(); let (req, _) = req.into_parts();
let res = named_file.into_response(&req); let res = named_file
.set_size_threshold(self.size_threshold)
.into_response(&req);
ServiceResponse::new(req, res) ServiceResponse::new(req, res)
} }
@ -169,17 +172,7 @@ impl Service<ServiceRequest> for FilesService {
} }
} else { } else {
match NamedFile::open_async(&path).await { match NamedFile::open_async(&path).await {
Ok(mut named_file) => { Ok(named_file) => Ok(this.serve_named_file(req, named_file)),
if let Some(ref mime_override) = this.mime_override {
let new_disposition = mime_override(&named_file.content_type.type_());
named_file.content_disposition.disposition = new_disposition;
}
named_file.flags = this.file_flags;
let (req, _) = req.into_parts();
let res = named_file.into_response(&req);
Ok(ServiceResponse::new(req, res))
}
Err(err) => this.handle_err(err, req).await, Err(err) => this.handle_err(err, req).await,
} }
} }