mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-09 05:18:23 +01:00
* rust: migrate seaweed-volume and seaweed-worker to tonic 0.14 / prost 0.14
tonic 0.14 boxes the contents of tonic::Status, which is what made every
RPC path trip clippy's result_large_err; the allow for that lint goes in
the next commit. The prost codec moved out of tonic into tonic-prost and
tonic-prost-build, so both build scripts now call
tonic_prost_build::configure() and both crates depend on tonic-prost for
the generated code. The `tls` feature was split into a per-backend
feature; `tls-aws-lc` is the same backend both crates already install
through rustls::crypto::aws_lc_rs.
tonic 0.14 depends on axum 0.8 and tower 0.5, which would have left a
second axum and a second tower in each tree next to the 0.7 / 0.4 the
crates named themselves. Bumping them keeps one copy of each: axum 0.8
only changes the path-parameter syntax for the routes here (`/:vid` ->
`/{vid}`, `/*path` -> `/{*path}`), tower 0.5 needs the `util` feature
named explicitly for ServiceExt::oneshot (it used to arrive through
tonic's feature unification), and tower-http 0.6 is the matching
release.
Lock files move only through cargo's own resolution for the new
versions; no other dependency was refreshed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU
* rust: drop the result_large_err allow now that tonic::Status is boxed
tonic 0.14 stores Status behind a Box, so Result<_, Status> is no longer
a large-Err type and clippy has nothing to say about it. Both crates
pass `cargo clippy --all-targets -- -D warnings` without the allow
(seaweed-volume in both feature sets), so the policy entry and its
comment go.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU
* rust-volume: drop the unused headers argument of try_expand_chunk_manifest
The parameter was already named `_headers`; nothing in the body reads it.
With it gone the function is under clippy's argument threshold and the
expect goes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU
* rust-volume: pass EC peer reads an EcInterval instead of ten arguments
fetch_one_interval, read_remote_ec_shard_interval,
do_read_remote_ec_shard_interval and recover_one_remote_ec_shard_interval
all took the same (vid, needle_id, shard_id, shard_offset, size,
expected_encode_ts_ns) tuple, and the two that reconstruct also took the
location map with the data/parity counts. Those are now EcInterval (Copy)
and EcShardMap (a borrow of the map plus the counts). The fan-out inside
recovery builds its per-shard request with `EcInterval { shard_id: sid,
..iv }`, which is the one place the old argument list was easy to get
wrong. Bodies destructure at the top, so the code below the signatures
is unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU
* rust-volume: give the EC encoder an EcEncodeLayout and an EncodeRun
encode_dat_file took the Reed-Solomon shape and three block sizes as five
loose integers; they are now one Copy struct, EcEncodeLayout, which is
what Go calls ECContext. The per-row and per-batch helpers took the same
six sinks and the offsets; they become methods on EncodeRun, which owns
the borrows for one run, so each call names only the offset and block
size that vary. The byte-level work is unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU
* rust-volume: describe a .dat rebuild with DatRebuild instead of nine arguments
write_dat_file_from_shards, its _with_dirs twin and the private
write_dat_file were three layers over one nine-argument signature. One
public function now takes a DatRebuild, whose shard_dirs is None when
every shard sits beside the .dat and Some(dirs) for the cross-disk
reconciled layout. The field docs carry what the function doc used to
say about the encode-time size and the block layout.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU
* rust-volume: split copy_file_from_source's fifteen arguments into two structs
CopyFileSpec is the per-file request (what to ask the source for, where
it lands, whether its bytes count as progress); CopyProgress is the
sender, throttler and report state that all three files of one
VolumeCopy share, held by &mut across the calls. The three production
call sites now read as the .dat/.idx/.vif literals they are, instead of
positional trues and falses.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU
* rust-volume: create volumes from a VolumeSpec
Volume::new, DiskLocation::create_volume and Store::add_volume each
took the same five-value tail of Go's NewVolume argument list:
collection, replica placement, TTL, preallocation and needle version.
That tail is now VolumeSpec, a Copy struct whose Default is what almost
every test wanted anyway (empty collection, no replication, no TTL, no
preallocation, current version), so most of the 104 call sites shrink
to `&VolumeSpec::default()` or name the one field they set. The id,
directories, index kind and disk type stay positional because they
differ at every site.
Two imports that only test modules use moved into those modules, and
DiskLocation no longer imports ReplicaPlacement.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU
* rust-worker: run cargo fmt
Layout only; no token in the workspace changes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU
* rust-volume: run cargo fmt
Layout only; no token in the crate changes. Every earlier Rust PR here
formatted only the blocks it touched so as not to drown its diff in
this one, and this commit is that debt paid in a single place. rustfmt
needed two passes to settle one block in handlers.rs; the committed
form is the fixed point, so `cargo fmt --check` is clean.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU
* ci: add a commented-out cargo fmt --check step to both Rust workflows
Same shape as the commented clippy step from #11312: the check is
written out so that making formatting a gate is a one-line uncomment,
and whether to do that stays a maintainer call.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjZY429aVU74SLDmo1wiuU
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
340 lines
12 KiB
Rust
340 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(),
|
|
s3_tier_registry: std::sync::RwLock::new(
|
|
crate::remote_storage::s3_tier::S3TierRegistry::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
|
|
}
|
|
}
|