use super::requests::{IoRequest, UringOpType};
use io_uring::{opcode, types, IoUring};
use std::cell::Cell;
use std::collections::HashMap;
use std::io;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, SyncSender, TryRecvError};
use std::sync::{Arc, LazyLock, OnceLock};
use std::time::Duration;
static URING_CONFIG: OnceLock<UringConfig> = OnceLock::new();
struct UringConfig {
queue_depth: usize,
thread_count: usize,
}
pub fn init_uring_config(queue_depth: usize, thread_count: usize) {
let _ = URING_CONFIG.set(UringConfig {
queue_depth: resolve_or_default(queue_depth, default_queue_depth()),
thread_count: resolve_or_default(thread_count, default_thread_count()),
});
}
struct UringThreadHandle {
request_tx: SyncSender<Arc<IoRequest>>,
}
static URING_THREADS: LazyLock<Vec<UringThreadHandle>> = LazyLock::new(|| {
let queue_depth = get_queue_depth();
let thread_count = get_thread_count();
let mut threads = Vec::with_capacity(thread_count);
for i in 0..thread_count {
let (tx, rx) = std::sync::mpsc::sync_channel(queue_depth);
std::thread::Builder::new()
.name(format!("gfs-uring-{i}"))
.spawn(move || run_uring_thread(rx, queue_depth as u32, i))
.expect("Failed to spawn io_uring thread");
threads.push(UringThreadHandle { request_tx: tx });
}
tracing::info!(
thread_count,
queue_depth,
"io_uring thread pool initialised for page cache"
);
threads
});
static THREAD_SELECTOR: AtomicU64 = AtomicU64::new(0);
static USER_DATA_COUNTER: AtomicU64 = AtomicU64::new(1);
const DEFAULT_SUBMIT_BATCH_SIZE: usize = 128;
const DEFAULT_POLL_TIMEOUT: Duration = Duration::from_millis(1);
pub fn try_submit_request(request: Arc<IoRequest>) -> bool {
let thread_idx =
(THREAD_SELECTOR.fetch_add(1, Ordering::Relaxed) as usize) % URING_THREADS.len();
URING_THREADS[thread_idx]
.request_tx
.try_send(request)
.is_ok()
}
pub fn submit_request(request: Arc<IoRequest>) {
let thread_idx =
(THREAD_SELECTOR.fetch_add(1, Ordering::Relaxed) as usize) % URING_THREADS.len();
match URING_THREADS[thread_idx]
.request_tx
.try_send(Arc::clone(&request))
{
Ok(()) => {}
Err(std::sync::mpsc::TrySendError::Full(_)) => {
request.fail(io::Error::new(
io::ErrorKind::WouldBlock,
"io_uring submission channel full",
));
}
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
request.fail(io::Error::new(
io::ErrorKind::BrokenPipe,
"io_uring thread died",
));
}
}
}
fn run_uring_thread(request_rx: Receiver<Arc<IoRequest>>, queue_depth: u32, thread_id: usize) {
let mut ring = match IoUring::builder().build(queue_depth) {
Ok(r) => r,
Err(e) => {
tracing::error!(error = %e, thread_id, "failed to create io_uring; thread exiting");
return;
}
};
let mut pending: HashMap<u64, Arc<IoRequest>> = HashMap::with_capacity(queue_depth as usize);
let submit_batch_size = DEFAULT_SUBMIT_BATCH_SIZE;
thread_local! {
static SPIN_COUNT: Cell<u32> = Cell::new(0);
}
loop {
let retries = process_completions(&mut ring, &mut pending);
let mut needs_submit = false;
for request in retries {
match push_to_sq(&mut ring, &mut pending, request) {
Ok(()) => needs_submit = true,
Err(e) => {
tracing::error!(error = %e, "failed to resubmit short io_uring op")
}
}
}
if !pending.is_empty() {
if needs_submit {
SPIN_COUNT.with(|c| c.set(0));
}
}
let mut batch_count = 0usize;
let mut should_exit = false;
loop {
let request = if pending.is_empty() && batch_count == 0 {
match request_rx.recv_timeout(DEFAULT_POLL_TIMEOUT) {
Ok(req) => Some(req),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => None,
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
should_exit = true;
None
}
}
} else {
match request_rx.try_recv() {
Ok(req) => Some(req),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => {
should_exit = true;
None
}
}
};
match request {
Some(request) => {
if let Err(e) = push_to_sq(&mut ring, &mut pending, request) {
tracing::error!(error = %e, "failed to push to io_uring SQ");
} else {
batch_count += 1;
}
if batch_count >= submit_batch_size {
break;
}
}
None => break,
}
}
if should_exit {
if batch_count > 0 {
let _ = ring.submit();
}
tracing::info!(thread_id, "io_uring thread shutting down");
return;
}
if batch_count > 0 || needs_submit {
if let Err(e) = ring.submit() {
tracing::error!(error = %e, batch_count, "failed to submit io_uring batch");
}
}
if !pending.is_empty() && batch_count == 0 {
let should_yield = SPIN_COUNT.with(|c| {
let n = c.get().saturating_add(1);
c.set(n);
n % 32 == 0
});
if should_yield {
std::thread::yield_now();
} else {
std::hint::spin_loop();
}
continue;
}
}
}
fn buffer_usable_for_transfer(transferred: usize, total: usize, buffer_len: usize) -> bool {
transferred < total && buffer_len >= total
}
fn push_to_sq(
ring: &mut IoUring,
pending: &mut HashMap<u64, Arc<IoRequest>>,
request: Arc<IoRequest>,
) -> io::Result<()> {
let user_data = USER_DATA_COUNTER.fetch_add(1, Ordering::Relaxed);
let sqe = match request.op_type {
UringOpType::Read => {
let (buf_ptr, read_offset, read_len) = {
let state = request.state.lock().unwrap();
let br = state.bytes_transferred;
if !buffer_usable_for_transfer(br, request.length, state.buffer.len()) {
drop(state);
let msg = "io_uring read buffer no longer valid for retry";
request.fail(io::Error::other(msg));
return Err(io::Error::other(msg));
}
(
unsafe { state.buffer.as_ptr().add(br) as *mut u8 },
request.offset + br as u64,
(request.length - br) as u32,
)
};
opcode::Read::new(types::Fd(request.fd), buf_ptr, read_len)
.offset(read_offset)
.build()
}
UringOpType::Write => {
let (buf_ptr, write_offset, write_len) = {
let state = request.state.lock().unwrap();
let bt = state.bytes_transferred;
if !buffer_usable_for_transfer(bt, request.length, state.buffer.len()) {
drop(state);
let msg = "io_uring write buffer no longer valid for retry";
request.fail(io::Error::other(msg));
return Err(io::Error::other(msg));
}
(
unsafe { state.buffer.as_ptr().add(bt) as *const u8 },
request.offset + bt as u64,
(request.length - bt) as u32,
)
};
opcode::Write::new(types::Fd(request.fd), buf_ptr, write_len)
.offset(write_offset)
.build()
}
UringOpType::OpenAt => {
let state = request.state.lock().unwrap();
let path_ptr = state.buffer.as_ptr() as *const libc::c_char;
opcode::OpenAt::new(types::Fd(request.fd), path_ptr)
.flags(request.open_flags | libc::O_CLOEXEC)
.mode(0o644)
.build()
}
UringOpType::Close => opcode::Close::new(types::Fd(request.fd)).build(),
UringOpType::UnlinkAt => {
let state = request.state.lock().unwrap();
let path_ptr = state.buffer.as_ptr() as *const libc::c_char;
opcode::UnlinkAt::new(types::Fd(request.fd), path_ptr).build()
}
}
.user_data(user_data);
let mut sq = ring.submission();
if sq.is_full() {
drop(sq);
request.fail(io::Error::new(
io::ErrorKind::WouldBlock,
"io_uring submission queue full",
));
return Err(io::Error::new(
io::ErrorKind::WouldBlock,
"io_uring submission queue full",
));
}
unsafe {
if sq.push(&sqe).is_err() {
drop(sq);
request.fail(io::Error::other("Failed to push to SQ"));
return Err(io::Error::other("Failed to push to SQ"));
}
}
drop(sq);
pending.insert(user_data, request);
Ok(())
}
fn process_completions(
ring: &mut IoUring,
pending: &mut HashMap<u64, Arc<IoRequest>>,
) -> Vec<Arc<IoRequest>> {
let mut retries = Vec::new();
for cqe in ring.completion() {
let user_data = cqe.user_data();
let result = cqe.result();
let Some(request) = pending.remove(&user_data) else {
tracing::warn!(user_data, "CQE for unknown user_data");
continue;
};
let mut state = request.state.lock().unwrap();
if result < 0 {
state.err = Some(io::Error::from_raw_os_error(-result));
state.completed = true;
} else {
match request.op_type {
UringOpType::Read => {
let n = result as usize;
if n == 0 {
let bytes_transferred = state.bytes_transferred;
state.buffer.truncate(bytes_transferred);
state.result_code = bytes_transferred as i32;
state.completed = true;
} else {
state.bytes_transferred += n;
if state.bytes_transferred >= request.length {
let bytes_transferred = state.bytes_transferred;
state.buffer.truncate(bytes_transferred);
state.result_code = bytes_transferred as i32;
state.completed = true;
} else {
drop(state);
retries.push(request);
continue;
}
}
}
UringOpType::Write => {
let n = result as usize;
state.bytes_transferred += n;
if state.bytes_transferred >= request.length {
state.result_code = 0;
state.completed = true;
} else {
drop(state);
retries.push(request);
continue;
}
}
UringOpType::OpenAt => {
state.result_code = result;
state.completed = true;
}
UringOpType::Close | UringOpType::UnlinkAt => {
state.result_code = 0;
state.completed = true;
}
}
}
if let Some(waker) = state.waker.take() {
drop(state);
waker.wake();
}
}
retries
}
fn default_queue_depth() -> usize {
std::env::var("GOOSEFS_USER_CLIENT_CACHE_URING_QUEUE_DEPTH")
.ok()
.and_then(|s| s.parse().ok())
.filter(|&d: &usize| d > 0)
.unwrap_or(16384)
}
fn default_thread_count() -> usize {
std::env::var("GOOSEFS_USER_CLIENT_CACHE_URING_THREAD_COUNT")
.ok()
.and_then(|s| s.parse().ok())
.filter(|&t: &usize| t > 0)
.unwrap_or(8)
}
fn get_queue_depth() -> usize {
if let Some(config) = URING_CONFIG.get() {
return config.queue_depth;
}
default_queue_depth()
}
fn get_thread_count() -> usize {
if let Some(config) = URING_CONFIG.get() {
return config.thread_count;
}
default_thread_count()
}
fn resolve_or_default(configured: usize, default: usize) -> usize {
if configured == 0 {
default
} else {
configured
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zero_means_unset_not_one() {
assert_eq!(resolve_or_default(0, 16384), 16384, "0 must fall back");
assert_eq!(resolve_or_default(0, 8), 8);
assert_eq!(resolve_or_default(1, 16384), 1);
assert_eq!(resolve_or_default(64, 16384), 64);
assert_eq!(resolve_or_default(16384, 16384), 16384);
}
#[test]
fn defaults_are_deep_enough_for_a_multi_op_put() {
const OPS_PER_PUT: usize = 3;
let depth = default_queue_depth();
assert!(
depth >= OPS_PER_PUT * 16,
"default queue depth {depth} leaves no headroom over the {OPS_PER_PUT} ops \
a single put issues"
);
let threads = default_thread_count();
assert!(threads >= 1, "default thread count must be at least 1");
}
#[test]
fn env_var_zero_is_ignored() {
let parsed: Option<usize> = "0".parse().ok();
assert_eq!(parsed.filter(|&d: &usize| d > 0), None);
let parsed: Option<usize> = "64".parse().ok();
assert_eq!(parsed.filter(|&d: &usize| d > 0), Some(64));
}
#[test]
fn partially_transferred_buffer_is_still_usable() {
assert!(buffer_usable_for_transfer(0, 4096, 4096), "fresh request");
assert!(buffer_usable_for_transfer(1, 4096, 4096), "1 byte in");
assert!(buffer_usable_for_transfer(4095, 4096, 4096), "1 byte left");
}
#[test]
fn taken_buffer_is_rejected() {
assert!(
!buffer_usable_for_transfer(0, 4096, 0),
"empty buffer must never be handed to the kernel"
);
assert!(!buffer_usable_for_transfer(2048, 4096, 0));
assert!(!buffer_usable_for_transfer(0, 4096, 4095));
}
#[test]
fn fully_transferred_request_is_not_retried() {
assert!(
!buffer_usable_for_transfer(4096, 4096, 4096),
"exactly done"
);
assert!(
!buffer_usable_for_transfer(5000, 4096, 4096),
"over-transferred would wrap `length - transferred`"
);
}
#[test]
fn failing_a_request_completes_it_for_the_waiter() {
let request = Arc::new(IoRequest {
fd: -1,
offset: 0,
length: 4096,
op_type: UringOpType::Read,
open_flags: 0,
state: std::sync::Mutex::new(crate::cache::store::uring::requests::RequestState {
completed: false,
consumed: false,
waker: None,
err: None,
buffer: bytes::BytesMut::new(),
bytes_transferred: 0,
result_code: 0,
}),
});
request.fail(io::Error::other("buffer no longer valid"));
let state = request.state.lock().unwrap();
assert!(state.completed, "a failed request must be observable");
assert!(state.err.is_some(), "the error must reach the caller");
}
}