mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-06 14:31:57 +02:00
* rust volume: one S3 tier registry instead of two kept in sync by hand `VolumeServerState.s3_tier_registry` and `global_s3_tier_registry()` held the same S3 tier backends. `apply_storage_backends` — the only production writer — registered every backend into both, and each half of the tiering code then read a different one: the gRPC tier-move handlers resolved the backend from the per-server field, while `Volume`'s remote mount and destroy paths resolved it from the global registry, because a `Volume` has no handle to the server state. Two registries that must agree, kept in agreement by a duplicated `register_s3_backend` call and a comment in a test constructor explaining the hand-sync. Delete the field and let both tier-move handlers resolve from the global registry, so `apply_storage_backends` registers once and no longer needs the server state at all. Injecting a registry handle through `VolumeSpec` instead was considered and rejected here: it would touch every `Volume` constructor for no functional gain, and the process-wide registry is what `Volume` already uses. Behaviour is unchanged: the same names were registered in both registries, so every lookup resolves exactly as before. The tier-down test now registers its backend only in the global registry — before this change it fails with `remote storage s3.tier_down_delete not found from supported: []`. The tier-up handler had no test at all, so it gets a cheap probe: register a backend only in the global registry, ask for that destination, and check the call gets past the lookup — the response is dropped straight away, so the transfer sees a departed caller and never opens a connection. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * rust volume: await the tier-up probe terminal error instead of racing it Dropping the response left it to chance whether the detached transfer saw the closed channel before its initial check; if it won that race it went on to attempt the multipart upload with no one waiting on the outcome. Hold the stream and read until the dead endpoint fails the upload — the terminal error proves the task ran and finished, so no background network work outlives the test. --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Chris Lu <chris.lu@gmail.com>
337 lines
12 KiB
Rust
337 lines
12 KiB
Rust
//! Async batched write processing for the volume server.
|
|
//!
|
|
//! Instead of each upload handler directly calling `write_needle`, writes are
|
|
//! submitted to a queue. A background worker drains the queue in batches (up to
|
|
//! 128 entries), groups them by volume ID, and processes them together under a
|
|
//! single store lock. Requests that asked for `fsync` are flushed by
|
|
//! `write_needle` itself, one flush per durable write.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use tokio::sync::{mpsc, oneshot};
|
|
use tracing::debug;
|
|
|
|
use crate::storage::needle::needle::Needle;
|
|
use crate::storage::types::{Size, VolumeId};
|
|
use crate::storage::volume::VolumeError;
|
|
|
|
use super::volume_server::VolumeServerState;
|
|
|
|
/// Result of a single write operation: (offset, size, is_unchanged).
|
|
pub type WriteResult = Result<(u64, Size, bool), VolumeError>;
|
|
|
|
/// The needles queued for one volume, each with the durability it asked for.
|
|
type VolumeBatch = Vec<(Needle, bool, oneshot::Sender<WriteResult>)>;
|
|
|
|
/// A request to write a needle, submitted to the write queue.
|
|
pub struct WriteRequest {
|
|
pub volume_id: VolumeId,
|
|
pub needle: Needle,
|
|
pub fsync: bool,
|
|
pub response_tx: oneshot::Sender<WriteResult>,
|
|
}
|
|
|
|
/// Maximum number of write requests to batch together.
|
|
const MAX_BATCH_SIZE: usize = 128;
|
|
|
|
/// Maximum bytes to accumulate per batch before breaking (matches Go's 4MB limit).
|
|
/// This prevents large writes from accumulating unbounded latency.
|
|
const MAX_BATCH_BYTES: usize = 4 * 1024 * 1024;
|
|
|
|
/// Handle for submitting write requests to the background worker.
|
|
#[derive(Clone)]
|
|
pub struct WriteQueue {
|
|
tx: mpsc::Sender<WriteRequest>,
|
|
}
|
|
|
|
impl WriteQueue {
|
|
/// Create a new write queue and spawn the background worker.
|
|
///
|
|
/// `capacity` controls the channel buffer size (backpressure kicks in when full).
|
|
/// The worker holds a reference to `state` for accessing the store.
|
|
pub fn new(state: Arc<VolumeServerState>, capacity: usize) -> Self {
|
|
let (tx, rx) = mpsc::channel(capacity);
|
|
let worker = WriteQueueWorker { rx, state };
|
|
tokio::spawn(worker.run());
|
|
WriteQueue { tx }
|
|
}
|
|
|
|
/// Submit a write request and wait for the result.
|
|
///
|
|
/// Returns `Err` if the worker has shut down or the response channel was dropped.
|
|
pub async fn submit(&self, volume_id: VolumeId, needle: Needle, fsync: bool) -> WriteResult {
|
|
let (response_tx, response_rx) = oneshot::channel();
|
|
let request = WriteRequest {
|
|
volume_id,
|
|
needle,
|
|
fsync,
|
|
response_tx,
|
|
};
|
|
|
|
// Send to queue; this awaits if the channel is full (backpressure).
|
|
if self.tx.send(request).await.is_err() {
|
|
return Err(VolumeError::Io(std::io::Error::new(
|
|
std::io::ErrorKind::BrokenPipe,
|
|
"write queue worker has shut down",
|
|
)));
|
|
}
|
|
|
|
// Wait for the worker to process our request.
|
|
match response_rx.await {
|
|
Ok(result) => result,
|
|
Err(_) => Err(VolumeError::Io(std::io::Error::new(
|
|
std::io::ErrorKind::BrokenPipe,
|
|
"write queue worker dropped response channel",
|
|
))),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Background worker that drains write requests and processes them in batches.
|
|
struct WriteQueueWorker {
|
|
rx: mpsc::Receiver<WriteRequest>,
|
|
state: Arc<VolumeServerState>,
|
|
}
|
|
|
|
impl WriteQueueWorker {
|
|
async fn run(mut self) {
|
|
debug!("write queue worker started");
|
|
|
|
loop {
|
|
// Wait for the first request (blocks until one arrives or channel closes).
|
|
let first = match self.rx.recv().await {
|
|
Some(req) => req,
|
|
None => {
|
|
debug!("write queue channel closed, worker exiting");
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Drain as many additional requests as available, up to MAX_BATCH_SIZE
|
|
// or MAX_BATCH_BYTES (matches Go: 128 requests or 4MB, whichever comes first).
|
|
let mut batch = Vec::with_capacity(MAX_BATCH_SIZE);
|
|
let mut batch_bytes: usize = first.needle.data.len();
|
|
batch.push(first);
|
|
|
|
while batch.len() < MAX_BATCH_SIZE && batch_bytes < MAX_BATCH_BYTES {
|
|
match self.rx.try_recv() {
|
|
Ok(req) => {
|
|
batch_bytes += req.needle.data.len();
|
|
batch.push(req);
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
|
|
let batch_size = batch.len();
|
|
debug!("processing write batch of {} requests", batch_size);
|
|
|
|
// Process the batch in spawn_blocking since write_needle does file I/O.
|
|
let state = self.state.clone();
|
|
let _ = tokio::task::spawn_blocking(move || {
|
|
process_batch(state, batch);
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Process a batch of write requests, grouped by volume ID.
|
|
///
|
|
/// Groups writes by volume to minimize the number of store lock acquisitions,
|
|
/// then sends results back via each request's oneshot channel.
|
|
fn process_batch(state: Arc<VolumeServerState>, batch: Vec<WriteRequest>) {
|
|
// Group requests by volume ID for efficient processing.
|
|
// We use a Vec of (VolumeId, Vec<(Needle, Sender)>) to preserve order
|
|
// and avoid requiring Hash on VolumeId.
|
|
let mut groups: Vec<(VolumeId, VolumeBatch)> = Vec::new();
|
|
|
|
for req in batch {
|
|
let vid = req.volume_id;
|
|
if let Some(group) = groups.iter_mut().find(|(v, _)| *v == vid) {
|
|
group.1.push((req.needle, req.fsync, req.response_tx));
|
|
} else {
|
|
groups.push((vid, vec![(req.needle, req.fsync, req.response_tx)]));
|
|
}
|
|
}
|
|
|
|
// Process each volume group under a single store lock.
|
|
let mut store = state.store.write().unwrap();
|
|
|
|
for (vid, entries) in groups {
|
|
for (mut needle, fsync, response_tx) in entries {
|
|
let result = store.write_volume_needle(vid, &mut needle, fsync);
|
|
// Send result back; ignore error if receiver dropped.
|
|
let _ = response_tx.send(result);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::storage::types::VolumeId;
|
|
|
|
/// Helper to create a minimal VolumeServerState for testing.
|
|
fn make_test_state() -> Arc<VolumeServerState> {
|
|
use crate::security::{Guard, SigningKey};
|
|
use crate::server::volume_server::RuntimeMetricsConfig;
|
|
use crate::storage::needle_map::NeedleMapKind;
|
|
use crate::storage::store::Store;
|
|
use std::sync::RwLock;
|
|
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32};
|
|
|
|
let store = Store::new(NeedleMapKind::InMemory);
|
|
let guard = Guard::new(&[], SigningKey(vec![]), 0, SigningKey(vec![]), 0);
|
|
|
|
Arc::new(VolumeServerState {
|
|
store: RwLock::new(store),
|
|
guard: RwLock::new(guard),
|
|
is_stopping: RwLock::new(false),
|
|
maintenance: AtomicBool::new(false),
|
|
state_version: AtomicU32::new(0),
|
|
concurrent_upload_limit: 0,
|
|
concurrent_download_limit: 0,
|
|
inflight_upload_data_timeout: std::time::Duration::ZERO,
|
|
inflight_download_data_timeout: std::time::Duration::ZERO,
|
|
inflight_upload_bytes: AtomicI64::new(0),
|
|
inflight_download_bytes: AtomicI64::new(0),
|
|
upload_notify: tokio::sync::Notify::new(),
|
|
download_notify: tokio::sync::Notify::new(),
|
|
data_center: String::new(),
|
|
rack: String::new(),
|
|
file_size_limit_bytes: 0,
|
|
maintenance_byte_per_second: 0,
|
|
is_heartbeating: AtomicBool::new(false),
|
|
has_master: false,
|
|
pre_stop_seconds: 0,
|
|
volume_state_notify: tokio::sync::Notify::new(),
|
|
write_queue: std::sync::OnceLock::new(),
|
|
read_mode: crate::config::ReadMode::Local,
|
|
allow_untrusted_remote_endpoints: false,
|
|
master_url: String::new(),
|
|
master_urls: Vec::new(),
|
|
seed_master_set: std::collections::HashSet::new(),
|
|
current_master_url: tokio::sync::RwLock::new(String::new()),
|
|
self_url: String::new(),
|
|
http_client: reqwest::Client::new(),
|
|
outgoing_http_scheme: "http".to_string(),
|
|
outgoing_grpc_tls: None,
|
|
metrics_runtime: std::sync::RwLock::new(RuntimeMetricsConfig::default()),
|
|
metrics_notify: tokio::sync::Notify::new(),
|
|
fix_jpg_orientation: false,
|
|
has_slow_read: true,
|
|
read_buffer_size_bytes: 4 * 1024 * 1024,
|
|
security_file: String::new(),
|
|
cli_white_list: vec![],
|
|
state_file_path: String::new(),
|
|
})
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_write_queue_submit_no_volume() {
|
|
// Submit a write to a non-existent volume -- should return VolumeError::NotFound.
|
|
let state = make_test_state();
|
|
let queue = WriteQueue::new(state, MAX_BATCH_SIZE);
|
|
|
|
let needle = Needle {
|
|
id: 1.into(),
|
|
cookie: 0x12345678.into(),
|
|
data: vec![1, 2, 3],
|
|
data_size: 3,
|
|
..Needle::default()
|
|
};
|
|
|
|
let result = queue.submit(VolumeId(999), needle, false).await;
|
|
assert!(result.is_err());
|
|
match result {
|
|
Err(VolumeError::NotFound) => {} // expected
|
|
other => panic!("expected NotFound, got {:?}", other),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_write_queue_concurrent_submissions() {
|
|
// Submit multiple concurrent writes -- all should complete (with errors since no volume).
|
|
let state = make_test_state();
|
|
let queue = WriteQueue::new(state, MAX_BATCH_SIZE);
|
|
|
|
let mut handles = Vec::new();
|
|
for i in 0..10u64 {
|
|
let q = queue.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
let needle = Needle {
|
|
id: i.into(),
|
|
cookie: 0xABCD.into(),
|
|
data: vec![i as u8; 10],
|
|
data_size: 10,
|
|
..Needle::default()
|
|
};
|
|
q.submit(VolumeId(1), needle, false).await
|
|
}));
|
|
}
|
|
|
|
for handle in handles {
|
|
let result = handle.await.unwrap();
|
|
// All should fail with NotFound since there's no volume 1
|
|
assert!(matches!(result, Err(VolumeError::NotFound)));
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_write_queue_batching() {
|
|
// Verify that many concurrent writes get processed (testing the batching path).
|
|
let state = make_test_state();
|
|
let queue = WriteQueue::new(state, MAX_BATCH_SIZE);
|
|
|
|
// Submit MAX_BATCH_SIZE requests concurrently
|
|
let mut handles = Vec::new();
|
|
for i in 0..MAX_BATCH_SIZE as u64 {
|
|
let q = queue.clone();
|
|
handles.push(tokio::spawn(async move {
|
|
let needle = Needle {
|
|
id: i.into(),
|
|
cookie: 0x1111.into(),
|
|
data: vec![0u8; 4],
|
|
data_size: 4,
|
|
..Needle::default()
|
|
};
|
|
q.submit(VolumeId(42), needle, false).await
|
|
}));
|
|
}
|
|
|
|
let mut results = Vec::new();
|
|
for handle in handles {
|
|
results.push(handle.await.unwrap());
|
|
}
|
|
|
|
// All should complete (with NotFound errors since no volume exists)
|
|
assert_eq!(results.len(), MAX_BATCH_SIZE);
|
|
for r in results {
|
|
assert!(matches!(r, Err(VolumeError::NotFound)));
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_write_queue_dropped_sender() {
|
|
// When the queue is dropped, subsequent submits should fail gracefully.
|
|
let state = make_test_state();
|
|
let queue = WriteQueue::new(state, 1);
|
|
|
|
// Clone then drop the original -- the worker keeps running via its rx handle.
|
|
let queue2 = queue.clone();
|
|
drop(queue);
|
|
|
|
// This should still work since the worker is alive.
|
|
let needle = Needle {
|
|
id: 1.into(),
|
|
cookie: 0.into(),
|
|
data: vec![],
|
|
data_size: 0,
|
|
..Needle::default()
|
|
};
|
|
let result = queue2.submit(VolumeId(1), needle, false).await;
|
|
assert!(result.is_err()); // NotFound is fine -- the point is it doesn't panic
|
|
}
|
|
}
|