mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-27 22:37:13 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a4878125b | |||
| e10631cc01 | |||
| fed6db1686 | |||
| afe755361a | |||
| aa229c8986 |
@@ -1,2 +1,2 @@
|
||||
sha256-linux=14becc458b9eb24add510d94ee6cd04ba1fb19907d598effe1524e5242400d10
|
||||
sha256-darwin=14becc458b9eb24add510d94ee6cd04ba1fb19907d598effe1524e5242400d10
|
||||
sha256-linux=0f8a240b5190a22c75b8c89fb07b81cd16027b013a85e71c90740a5b6004a5c5
|
||||
sha256-darwin=0f8a240b5190a22c75b8c89fb07b81cd16027b013a85e71c90740a5b6004a5c5
|
||||
|
||||
@@ -25,6 +25,7 @@ workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
scanner-peer-mixed-version = []
|
||||
hotpath = [
|
||||
"hotpath/hotpath",
|
||||
"hotpath/tokio",
|
||||
|
||||
@@ -64,6 +64,25 @@ Each build/run holds an exclusive `rustfs.e2e.lock` marker beside the binary; co
|
||||
|
||||
The protocols suite has its own fixed-port and single-worker contract in [`src/protocols/README.md`](src/protocols/README.md). Use its command under [Troubleshooting](#troubleshooting).
|
||||
|
||||
### Scanner mixed-version gate
|
||||
|
||||
The Linux N/O scanner gate needs a separately qualified `33482bb` server, which supports scoped ACKs. Set `RUSTFS_SCANNER_COMPAT_OLD_BINARY` to that executable and `RUSTFS_SCANNER_COMPAT_OLD_SHA256` to its independently verified digest. The release binary used by ordinary upgrade CI is a different baseline. Missing or mismatched inputs fail the test.
|
||||
|
||||
Enable the harness-only `scanner-peer-mixed-version` feature explicitly; keep the server wrapper features identical between build and run:
|
||||
|
||||
```bash
|
||||
python3 scripts/e2e_binary.py build --features e2e-test-hooks
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- \
|
||||
cargo nextest run --profile e2e-distributed -p e2e_test \
|
||||
--features scanner-peer-mixed-version --ignore-default-filter \
|
||||
-E 'test(=distributed::scanner_peer_bucket_test::scanner_peer_bucket_new_coordinator_certifies_old_peers_and_scopes_next_cycle)' \
|
||||
--test-threads 1 --retries 0 --no-tests=fail
|
||||
```
|
||||
|
||||
The separate `scanner_peer_bucket_new_coordinator_certifies_old_worker_peers_and_scopes_next_cycle` case uses the same feature and qualified old binary inputs. Select its exact name in the command above. It keeps the original N process enabled and starts the three official O replacements with `RUSTFS_SCANNER_ENABLED=false`: their autonomous scanner loops are disabled, while real S3 dirty tracking, authenticated snapshots, publication leases, scoped ACKs, and N-dispatched RemoteScanner workers remain active. It records each node's actual scanner status and retains the same identity, durability, root, ACK, data-conservation, and physical-walk requirements. This fixed-role case does not validate autonomous O scanning or all-enabled election behavior. The original all-enabled N/O test remains separate and has not passed acceptance; a fixed-role result cannot satisfy that gate.
|
||||
|
||||
For this fixed-role case, the `e2e-test-hooks` server also records the N client's complete bounded worker inventory. Requests are observed before opening the normal stream and paired only after authenticated source/plan validation and terminal EOF. Closed manifests and final health receipts must bind both cycles to their actual candidate/root; the next cycle needs exactly four sets of hot-only execution and at least one positive-progress worker mapped to a measured official O process. A zero-progress Current response, missing/extra request, unhealthy recorder, or missing physical coverage fails the gate. The recorder does not add O hooks or change any protocol or timeout. Final health reads check immutable closed-cycle evidence, not current N liveness.
|
||||
|
||||
### `#[ignore]` semantics
|
||||
|
||||
Ignored tests are excluded from the default `cargo nextest run` pass because
|
||||
|
||||
@@ -39,6 +39,8 @@ mod s3_during_data_movement_test;
|
||||
mod scanner_heal_topology_fixture;
|
||||
mod scanner_heal_topology_test;
|
||||
mod scanner_heal_topology_tier_test;
|
||||
mod scanner_peer_bucket_fixture;
|
||||
mod scanner_peer_bucket_test;
|
||||
mod site_replication_test;
|
||||
mod upgrade_test;
|
||||
mod versioning_test;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,7 @@ use hyper_util::{
|
||||
use prost::Message as _;
|
||||
use rustfs_protos::proto_gen::node_service::{
|
||||
BatchGenerallyLockRequest, DeleteRequest, GenerallyLockRequest, ReadVersionRequest, RecoverMultipartSourceRequest,
|
||||
RenameDataResponse,
|
||||
RenameDataResponse, ScannerScopedDirtyUsageAckRequest,
|
||||
};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
@@ -49,6 +49,9 @@ use tokio::{
|
||||
type ProxyBody = BoxBody<Bytes, io::Error>;
|
||||
type ProxyClient = Client<HttpConnector, ProxyBody>;
|
||||
|
||||
mod scoped_ack;
|
||||
pub(crate) use scoped_ack::{ScopedAckFault, ScopedAckQueue, ScopedAckScope};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Disposition {
|
||||
Hold,
|
||||
@@ -89,7 +92,8 @@ impl RpcPause {
|
||||
self.state.blocked.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Selected RPCs with successful HTTP/gRPC headers, excluding discarded packets.
|
||||
/// Selected RPCs with successful HTTP/gRPC response headers, excluding
|
||||
/// discarded packets. This does not prove a successful gRPC trailer or ACK apply.
|
||||
pub(crate) fn forwarded(&self) -> usize {
|
||||
self.state.forwarded.load(Ordering::SeqCst)
|
||||
}
|
||||
@@ -190,6 +194,7 @@ pub(crate) struct RpcFaultProxy {
|
||||
address: SocketAddr,
|
||||
slot: PauseSlot,
|
||||
lock_slot: PauseSlot,
|
||||
scoped_ack: scoped_ack::QueueSlot,
|
||||
task: JoinHandle<()>,
|
||||
late_requests: LateRequests,
|
||||
}
|
||||
@@ -202,6 +207,8 @@ impl RpcFaultProxy {
|
||||
let active = slot.clone();
|
||||
let lock_slot: PauseSlot = Arc::new(Mutex::new(None));
|
||||
let active_lock = lock_slot.clone();
|
||||
let scoped_ack = scoped_ack::empty_slot();
|
||||
let active_ack = scoped_ack.clone();
|
||||
let late_requests: LateRequests = Arc::new(Mutex::new(Some(Vec::new())));
|
||||
let late = late_requests.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
@@ -216,6 +223,7 @@ impl RpcFaultProxy {
|
||||
let h2 = h2.clone();
|
||||
let active = active.clone();
|
||||
let active_lock = active_lock.clone();
|
||||
let active_ack = active_ack.clone();
|
||||
let late = late.clone();
|
||||
connections.spawn(async move {
|
||||
let service = service_fn(move |request: Request<Incoming>| {
|
||||
@@ -229,6 +237,7 @@ impl RpcFaultProxy {
|
||||
None
|
||||
};
|
||||
let pause = owner_pause.or_else(|| active.lock().expect("RPC pause slot").clone());
|
||||
let ack = active_ack.lock().expect("scoped ACK queue slot").clone();
|
||||
let retain = pause.as_ref().is_some_and(|pause| {
|
||||
pause.retain_after_disconnect
|
||||
&& request.uri().path().strip_prefix("/node_service.NodeService/")
|
||||
@@ -237,11 +246,11 @@ impl RpcFaultProxy {
|
||||
let late = late.clone();
|
||||
async move {
|
||||
if !retain {
|
||||
return forward_request(request, client, target, pause).await;
|
||||
return forward_request(request, client, target, pause, ack).await;
|
||||
}
|
||||
// This opt-in fault owns the packet after client cancellation.
|
||||
// Proxy teardown still aborts every retained request.
|
||||
let task = tokio::spawn(forward_request(request, client, target, pause));
|
||||
let task = tokio::spawn(forward_request(request, client, target, pause, ack));
|
||||
{
|
||||
let mut requests = late.lock().expect("late RPC ownership");
|
||||
if let Some(requests) = requests.as_mut() {
|
||||
@@ -266,6 +275,7 @@ impl RpcFaultProxy {
|
||||
address,
|
||||
slot,
|
||||
lock_slot,
|
||||
scoped_ack,
|
||||
task,
|
||||
late_requests,
|
||||
})
|
||||
@@ -275,6 +285,11 @@ impl RpcFaultProxy {
|
||||
self.address
|
||||
}
|
||||
|
||||
/// Retain only genuine scoped ACKs for one receiver/session and bucket set.
|
||||
pub(crate) fn scoped_ack_queue(&self, scope: ScopedAckScope, capacity: usize) -> io::Result<ScopedAckQueue> {
|
||||
ScopedAckQueue::arm(self.scoped_ack.clone(), scope, capacity)
|
||||
}
|
||||
|
||||
/// Pause at most limit requests to the named unary methods; other streams
|
||||
/// on the same HTTP/2 connection continue forwarding.
|
||||
pub(crate) fn pause(&self, methods: &[&'static str], limit: usize, scope: Option<&str>) -> io::Result<RpcPause> {
|
||||
@@ -423,6 +438,9 @@ fn unary_payload(body: &[u8]) -> io::Result<&[u8]> {
|
||||
}
|
||||
|
||||
fn scope_matches(method: &str, body: &[u8], scope: &[u8]) -> io::Result<bool> {
|
||||
if method == "ScannerScopedDirtyUsageAck" {
|
||||
return scoped_ack_matches(body, Some(scope));
|
||||
}
|
||||
if method == "RecoverMultipartSource" {
|
||||
let request = RecoverMultipartSourceRequest::decode(unary_payload(body)?).map_err(io::Error::other)?;
|
||||
if request.protocol_version != u32::from(MULTIPART_SOURCE_PROTOCOL_VERSION) || request.path.as_bytes() != scope {
|
||||
@@ -479,7 +497,9 @@ fn scoped_lock_owners(method: &str, body: &[u8], scope: &[u8]) -> io::Result<Vec
|
||||
let args = match method {
|
||||
"Lock" => vec![GenerallyLockRequest::decode(payload).map_err(io::Error::other)?.args],
|
||||
"LockBatch" => BatchGenerallyLockRequest::decode(payload).map_err(io::Error::other)?.args,
|
||||
_ => return Err(io::Error::new(io::ErrorKind::InvalidInput, "lock observer requires a lock method")),
|
||||
_ => {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "lock observer requires a lock method"));
|
||||
}
|
||||
};
|
||||
args.into_iter()
|
||||
.filter(|args| args.as_bytes().windows(scope.len()).any(|window| window == scope))
|
||||
@@ -494,18 +514,39 @@ fn scoped_lock_owners(method: &str, body: &[u8], scope: &[u8]) -> io::Result<Vec
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn scoped_ack_matches(body: &[u8], scope: Option<&[u8]>) -> io::Result<bool> {
|
||||
let request = ScannerScopedDirtyUsageAckRequest::decode(unary_payload(body)?).map_err(io::Error::other)?;
|
||||
Ok(!request.probe_only && scope.is_none_or(|scope| request.entries.iter().any(|entry| entry.bucket.as_bytes() == scope)))
|
||||
}
|
||||
|
||||
async fn forward_request(
|
||||
request: Request<Incoming>,
|
||||
client: ProxyClient,
|
||||
target: SocketAddr,
|
||||
pause: Option<Arc<PauseState>>,
|
||||
ack: Option<Arc<scoped_ack::QueueState>>,
|
||||
) -> io::Result<hyper::Response<ProxyBody>> {
|
||||
let (mut parts, body) = request.into_parts();
|
||||
let mut body = body.map_err(io::Error::other).boxed();
|
||||
let request = request.map(|body| body.map_err(io::Error::other).boxed());
|
||||
let request = if let Some(ack) = ack
|
||||
&& request.uri().path() == "/node_service.ScannerControlService/ScannerScopedDirtyUsageAck"
|
||||
{
|
||||
match scoped_ack::intercept(request, client.clone(), target, ack).await? {
|
||||
scoped_ack::Interception::Pass(request) => request,
|
||||
scoped_ack::Interception::Handled(response) => return Ok(response),
|
||||
}
|
||||
} else {
|
||||
request
|
||||
};
|
||||
let (parts, body) = request.into_parts();
|
||||
let mut body = body;
|
||||
let mut observed = None;
|
||||
let mut inspection = None;
|
||||
if let Some(pause) = pause {
|
||||
let method = parts.uri.path().strip_prefix("/node_service.NodeService/");
|
||||
let method = parts
|
||||
.uri
|
||||
.path()
|
||||
.strip_prefix("/node_service.NodeService/")
|
||||
.or_else(|| parts.uri.path().strip_prefix("/node_service.ScannerControlService/"));
|
||||
if let Some(method) = method.filter(|name| pause.methods.contains(name)) {
|
||||
let mut route = None;
|
||||
let mut lock_owners = Vec::new();
|
||||
@@ -545,6 +586,16 @@ async fn forward_request(
|
||||
.with_trailers(async move { trailers.map(Ok::<_, io::Error>) })
|
||||
.boxed();
|
||||
matches
|
||||
} else if method == "ScannerScopedDirtyUsageAck" {
|
||||
let collected = Limited::new(body, 1024 * 1024).collect().await.map_err(io::Error::other)?;
|
||||
let trailers = collected.trailers().cloned();
|
||||
let bytes = collected.to_bytes();
|
||||
let matches = scoped_ack_matches(&bytes, None)?;
|
||||
body = Full::new(bytes)
|
||||
.map_err(|never| match never {})
|
||||
.with_trailers(async move { trailers.map(Ok::<_, io::Error>) })
|
||||
.boxed();
|
||||
matches
|
||||
} else {
|
||||
true
|
||||
};
|
||||
@@ -576,24 +627,7 @@ async fn forward_request(
|
||||
}
|
||||
}
|
||||
}
|
||||
if !parts.headers.contains_key(http::header::HOST)
|
||||
&& let Some(authority) = parts.uri.authority()
|
||||
{
|
||||
parts.headers.insert(
|
||||
http::header::HOST,
|
||||
http::HeaderValue::from_str(authority.as_str()).map_err(io::Error::other)?,
|
||||
);
|
||||
}
|
||||
parts.uri = http::Uri::builder()
|
||||
.scheme("http")
|
||||
.authority(target.to_string())
|
||||
.path_and_query(parts.uri.path_and_query().map_or("/", |value| value.as_str()))
|
||||
.build()
|
||||
.map_err(io::Error::other)?;
|
||||
let response = client
|
||||
.request(Request::from_parts(parts, body))
|
||||
.await
|
||||
.map_err(io::Error::other)?;
|
||||
let response = forward_to_backend(Request::from_parts(parts, body), client, target).await?;
|
||||
if let Some((pause, disk)) = inspection
|
||||
&& response.status().is_success()
|
||||
&& response.headers().get("grpc-status").is_some_and(|status| status == "12")
|
||||
@@ -642,6 +676,32 @@ async fn forward_request(
|
||||
Ok(response.map(|body| body.map_err(io::Error::other).boxed()))
|
||||
}
|
||||
|
||||
async fn forward_to_backend(
|
||||
request: Request<ProxyBody>,
|
||||
client: ProxyClient,
|
||||
target: SocketAddr,
|
||||
) -> io::Result<hyper::Response<Incoming>> {
|
||||
let (mut parts, body) = request.into_parts();
|
||||
if !parts.headers.contains_key(http::header::HOST)
|
||||
&& let Some(authority) = parts.uri.authority()
|
||||
{
|
||||
parts.headers.insert(
|
||||
http::header::HOST,
|
||||
http::HeaderValue::from_str(authority.as_str()).map_err(io::Error::other)?,
|
||||
);
|
||||
}
|
||||
parts.uri = http::Uri::builder()
|
||||
.scheme("http")
|
||||
.authority(target.to_string())
|
||||
.path_and_query(parts.uri.path_and_query().map_or("/", |value| value.as_str()))
|
||||
.build()
|
||||
.map_err(io::Error::other)?;
|
||||
client
|
||||
.request(Request::from_parts(parts, body))
|
||||
.await
|
||||
.map_err(io::Error::other)
|
||||
}
|
||||
|
||||
impl Drop for RpcFaultProxy {
|
||||
fn drop(&mut self) {
|
||||
if let Some(requests) = self.late_requests.lock().expect("late RPC ownership").take() {
|
||||
@@ -661,6 +721,7 @@ mod tests {
|
||||
|
||||
struct Backend {
|
||||
received: Arc<AtomicUsize>,
|
||||
auth_headers: Arc<Mutex<Vec<(String, String)>>>,
|
||||
address: SocketAddr,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
@@ -680,6 +741,8 @@ mod tests {
|
||||
let address = listener.local_addr()?;
|
||||
let received = Arc::new(AtomicUsize::new(0));
|
||||
let received_task = received.clone();
|
||||
let auth_headers = Arc::new(Mutex::new(Vec::new()));
|
||||
let backend_auth = auth_headers.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut children = JoinSet::new();
|
||||
loop {
|
||||
@@ -687,9 +750,11 @@ mod tests {
|
||||
accepted = listener.accept() => {
|
||||
let Ok((socket, _)) = accepted else { break };
|
||||
let received = received_task.clone();
|
||||
let backend_auth = backend_auth.clone();
|
||||
children.spawn(async move {
|
||||
let service = service_fn(move |request: Request<Incoming>| {
|
||||
let received = received.clone();
|
||||
let backend_auth = backend_auth.clone();
|
||||
async move {
|
||||
received.fetch_add(1, Ordering::SeqCst);
|
||||
let authority = request.headers().get(http::header::HOST).cloned().or_else(|| {
|
||||
@@ -697,6 +762,10 @@ mod tests {
|
||||
http::HeaderValue::from_str(authority.as_str()).expect("valid HTTP authority")
|
||||
})
|
||||
});
|
||||
if let (Some(nonce), Some(signature)) = (request.headers().get("x-rustfs-rpc-nonce"), request.headers().get("x-rustfs-rpc-signature-v2")) {
|
||||
backend_auth.lock().expect("backend auth header observations").push((
|
||||
nonce.to_str().expect("test nonce").to_string(), signature.to_str().expect("test signature").to_string()));
|
||||
}
|
||||
let body = request.into_body().collect().await?.to_bytes();
|
||||
let mut trailers = http::HeaderMap::new();
|
||||
trailers.insert("grpc-status", http::HeaderValue::from_static(status.unwrap_or("0")));
|
||||
@@ -718,7 +787,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(Backend { received, address, task })
|
||||
Ok(Backend {
|
||||
received,
|
||||
auth_headers,
|
||||
address,
|
||||
task,
|
||||
})
|
||||
}
|
||||
|
||||
async fn call(client: &ProxyClient, address: SocketAddr, method: &str) -> io::Result<hyper::Response<Incoming>> {
|
||||
@@ -732,9 +806,14 @@ mod tests {
|
||||
bytes: Bytes,
|
||||
) -> io::Result<hyper::Response<Incoming>> {
|
||||
let body: ProxyBody = Full::new(bytes).map_err(|never| match never {}).boxed();
|
||||
let service = if method == "ScannerScopedDirtyUsageAck" {
|
||||
"ScannerControlService"
|
||||
} else {
|
||||
"NodeService"
|
||||
};
|
||||
let request = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("http://{address}/node_service.NodeService/{method}"))
|
||||
.uri(format!("http://{address}/node_service.{service}/{method}"))
|
||||
.header(http::header::TE, "trailers")
|
||||
.body(body)
|
||||
.map_err(io::Error::other)?;
|
||||
@@ -749,6 +828,353 @@ mod tests {
|
||||
frame.into()
|
||||
}
|
||||
|
||||
const ACK_OWNER: &str = "11111111-1111-1111-1111-111111111111";
|
||||
const ACK_INSTANCE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
|
||||
fn ack_scope() -> ScopedAckScope {
|
||||
ScopedAckScope {
|
||||
nonce: uuid::Uuid::new_v4().to_string(),
|
||||
owner_id: ACK_OWNER.to_string(),
|
||||
instance_id: ACK_INSTANCE.to_string(),
|
||||
buckets: std::collections::BTreeSet::from(["cold-bucket".to_string()]),
|
||||
}
|
||||
}
|
||||
|
||||
fn ack_request(bucket: &str, generation: u64) -> ScannerScopedDirtyUsageAckRequest {
|
||||
ScannerScopedDirtyUsageAckRequest {
|
||||
challenge: vec![7; 16].into(),
|
||||
protocol_version: 1,
|
||||
owner_id: ACK_OWNER.to_string(),
|
||||
instance_id: ACK_INSTANCE.to_string(),
|
||||
scope: 1,
|
||||
probe_only: false,
|
||||
entries: vec![rustfs_protos::proto_gen::node_service::ScannerScopedDirtyUsageEntry {
|
||||
bucket: bucket.to_string(),
|
||||
bucket_incarnation: vec![4; 16].into(),
|
||||
generation,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn ack_frame(request: &ScannerScopedDirtyUsageAckRequest) -> Bytes {
|
||||
let payload = request.encode_to_vec();
|
||||
let mut frame = vec![0];
|
||||
frame.extend_from_slice(&u32::try_from(payload.len()).expect("bounded test frame").to_be_bytes());
|
||||
frame.extend_from_slice(&payload);
|
||||
Bytes::from(frame)
|
||||
}
|
||||
|
||||
fn ack_hash(bytes: &[u8]) -> String {
|
||||
use sha2::Digest as _;
|
||||
hex_simd::encode_to_string(&sha2::Sha256::digest(bytes)[..], hex_simd::AsciiCase::Lower)
|
||||
}
|
||||
|
||||
// Transport-only fixtures: the distributed tests supply production signatures.
|
||||
async fn call_ack_frame(
|
||||
client: &ProxyClient,
|
||||
address: SocketAddr,
|
||||
frame: Bytes,
|
||||
nonce: &str,
|
||||
) -> io::Result<hyper::Response<Incoming>> {
|
||||
let request = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("http://{address}/node_service.ScannerControlService/ScannerScopedDirtyUsageAck"))
|
||||
.header(http::header::TE, "trailers")
|
||||
.header("x-rustfs-rpc-nonce", nonce)
|
||||
.header("x-rustfs-rpc-signature-v2", format!("opaque-signature-{nonce}"))
|
||||
.body(Full::new(frame).map_err(|never| match never {}).boxed())
|
||||
.map_err(io::Error::other)?;
|
||||
client.request(request).await.map_err(io::Error::other)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scoped_ack_queue_loss_preserves_backend_boundary() {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
for fault in [ScopedAckFault::RequestLoss, ScopedAckFault::ResponseLoss] {
|
||||
let backend = backend().await.expect("backend");
|
||||
let proxy = RpcFaultProxy::start_on("127.0.0.1:0".parse().expect("loopback"), backend.address)
|
||||
.await
|
||||
.expect("proxy");
|
||||
let queue = proxy.scoped_ack_queue(ack_scope(), 1).expect("ACK queue");
|
||||
let client: ProxyClient = Client::builder(TokioExecutor::new()).http2_only(true).build_http();
|
||||
let frame = ack_frame(&ack_request("cold-bucket", 7));
|
||||
let address = proxy.local_addr();
|
||||
let body = frame.clone();
|
||||
let caller = tokio::spawn(async move { call_ack_frame(&client, address, body, "loss").await });
|
||||
queue.wait_for_packets(1).await.expect("captured ACK");
|
||||
assert_eq!(backend.received.load(Ordering::SeqCst), 0, "retention must precede backend dispatch");
|
||||
queue.settle(0, fault).expect("settle actual retained packet");
|
||||
assert!(
|
||||
caller.await.expect("caller task").is_err(),
|
||||
"the caller must observe the injected transport failure"
|
||||
);
|
||||
let receipt = queue.wait_for_completion(0).await.expect("complete fault receipt");
|
||||
let response_loss = fault == ScopedAckFault::ResponseLoss;
|
||||
assert_eq!(receipt["forwarded"], response_loss);
|
||||
assert_eq!(receipt["backend_complete"], response_loss);
|
||||
assert_eq!(backend.received.load(Ordering::SeqCst), usize::from(response_loss));
|
||||
if response_loss {
|
||||
assert_eq!(receipt["outcome"], "response_lost");
|
||||
assert_eq!(receipt["response"]["grpc_status"], "0", "status exists only in real backend trailers");
|
||||
assert_eq!(receipt["response"]["bytes"], frame.len());
|
||||
assert_eq!(receipt["response"]["sha256"], ack_hash(&frame));
|
||||
assert!(
|
||||
receipt["response"]["trailers_sha256"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.len() == 64)
|
||||
);
|
||||
} else {
|
||||
assert_eq!(receipt["outcome"], "request_lost");
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("bounded ACK loss checks");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scoped_ack_queue_keeps_cancelled_packets_and_releases_out_of_order() {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
let backend = backend().await.expect("backend");
|
||||
let proxy = RpcFaultProxy::start_on("127.0.0.1:0".parse().expect("loopback"), backend.address)
|
||||
.await
|
||||
.expect("proxy");
|
||||
let queue = proxy.scoped_ack_queue(ack_scope(), 2).expect("two-packet ACK queue");
|
||||
let client: ProxyClient = Client::builder(TokioExecutor::new()).http2_only(true).build_http();
|
||||
let first = ack_frame(&ack_request("cold-bucket", 7));
|
||||
let second = ack_frame(&ack_request("cold-bucket", 9));
|
||||
let address = proxy.local_addr();
|
||||
let old_client = client.clone();
|
||||
let old_body = first.clone();
|
||||
let old_caller = tokio::spawn(async move { call_ack_frame(&old_client, address, old_body, "first").await });
|
||||
queue.wait_for_packets(1).await.expect("first retained packet");
|
||||
old_caller.abort();
|
||||
assert!(old_caller.await.expect_err("cancel original caller").is_cancelled());
|
||||
let new_body = second.clone();
|
||||
let new_caller = tokio::spawn(async move { call_ack_frame(&client, address, new_body, "second").await });
|
||||
queue.wait_for_packets(2).await.expect("second retained packet");
|
||||
assert_eq!(backend.received.load(Ordering::SeqCst), 0);
|
||||
queue.settle(1, ScopedAckFault::Forward).expect("release newer packet first");
|
||||
let response = new_caller
|
||||
.await
|
||||
.expect("new caller task")
|
||||
.expect("new response")
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("complete body");
|
||||
assert_eq!(response.trailers().expect("response trailers")["x-proof"], "unchanged-proof");
|
||||
assert_eq!(response.to_bytes(), second);
|
||||
queue.wait_for_completion(1).await.expect("new packet complete");
|
||||
queue
|
||||
.settle(0, ScopedAckFault::Forward)
|
||||
.expect("release old packet after its caller disappeared");
|
||||
let old = queue.wait_for_completion(0).await.expect("detached old packet complete");
|
||||
assert_eq!(old["backend_complete"], true);
|
||||
assert_eq!(old["request_sha256"], ack_hash(&first));
|
||||
assert_eq!(old["response"]["sha256"], ack_hash(&first));
|
||||
assert_eq!(backend.received.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(
|
||||
*backend.auth_headers.lock().expect("backend header observations"),
|
||||
vec![
|
||||
("second".to_string(), "opaque-signature-second".to_string()),
|
||||
("first".to_string(), "opaque-signature-first".to_string())
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
queue.settle(0, ScopedAckFault::Forward).is_err(),
|
||||
"one original packet may only be forwarded once"
|
||||
);
|
||||
})
|
||||
.await
|
||||
.expect("bounded detached retention check");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scoped_ack_queue_bounds_capacity_and_excludes_other_scopes() {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
let backend = backend().await.expect("backend");
|
||||
let proxy = RpcFaultProxy::start_on("127.0.0.1:0".parse().expect("loopback"), backend.address)
|
||||
.await
|
||||
.expect("proxy");
|
||||
assert!(proxy.scoped_ack_queue(ack_scope(), 0).is_err());
|
||||
assert!(proxy.scoped_ack_queue(ack_scope(), 5).is_err());
|
||||
let mut invalid = ack_scope();
|
||||
invalid.nonce.clear();
|
||||
assert!(proxy.scoped_ack_queue(invalid, 1).is_err());
|
||||
let mut scope = ack_scope();
|
||||
scope.buckets.insert("cold-bucket-z".to_string());
|
||||
let queue = proxy.scoped_ack_queue(scope, 1).expect("bounded ACK queue");
|
||||
let client: ProxyClient = Client::builder(TokioExecutor::new()).http2_only(true).build_http();
|
||||
let mut probe = ack_request("cold-bucket", 7);
|
||||
probe.probe_only = true;
|
||||
let mut other_peer = ack_request("cold-bucket", 7);
|
||||
other_peer.owner_id = uuid::Uuid::new_v4().to_string();
|
||||
let mut other_instance = ack_request("cold-bucket", 7);
|
||||
other_instance.instance_id = "b".repeat(32);
|
||||
let mut invalid_challenge = ack_request("cold-bucket", 7);
|
||||
invalid_challenge.challenge = vec![7; 15].into();
|
||||
let mut noncanonical_entries = ack_request("cold-bucket-z", 7);
|
||||
noncanonical_entries.entries.extend(ack_request("cold-bucket", 7).entries);
|
||||
let unselected = [
|
||||
probe,
|
||||
other_peer,
|
||||
other_instance,
|
||||
ack_request("cold-bucket-other", 7),
|
||||
ack_request("cold-bucket", u64::MAX),
|
||||
invalid_challenge,
|
||||
noncanonical_entries,
|
||||
];
|
||||
let unselected_count = unselected.len();
|
||||
for (index, request) in unselected.into_iter().enumerate() {
|
||||
let body = ack_frame(&request);
|
||||
let response = call_ack_frame(&client, proxy.local_addr(), body.clone(), "unselected")
|
||||
.await
|
||||
.expect("unselected ACK progresses");
|
||||
assert_eq!(response.into_body().collect().await.expect("unselected body").to_bytes(), body);
|
||||
assert!(queue.receipts().is_empty(), "unselected or protocol-invalid ACK must not consume a slot");
|
||||
assert_eq!(backend.received.load(Ordering::SeqCst), index + 1);
|
||||
}
|
||||
assert!(queue.receipts().is_empty());
|
||||
let address = proxy.local_addr();
|
||||
let held_client = client.clone();
|
||||
let valid_frame = ack_frame(&ack_request("cold-bucket", 7));
|
||||
let held_body = valid_frame.clone();
|
||||
let held = tokio::spawn(async move { call_ack_frame(&held_client, address, held_body, "held").await });
|
||||
queue.wait_for_packets(1).await.expect("one matching packet");
|
||||
assert!(!held.is_finished(), "the subsequent valid ACK must be retained");
|
||||
assert_eq!(queue.receipts()[0]["request_sha256"], ack_hash(&valid_frame));
|
||||
assert_eq!(backend.received.load(Ordering::SeqCst), unselected_count);
|
||||
let extra = call_ack_frame(&client, address, ack_frame(&ack_request("cold-bucket", 8)), "beyond-capacity")
|
||||
.await
|
||||
.expect("capacity overflow passes without retention");
|
||||
extra.into_body().collect().await.expect("overflow response");
|
||||
assert_eq!(queue.receipts().len(), 1);
|
||||
assert_eq!(backend.received.load(Ordering::SeqCst), unselected_count + 1);
|
||||
drop(queue);
|
||||
assert!(
|
||||
held.await.expect("held caller").is_err(),
|
||||
"dropping the session must cancel its retained packet"
|
||||
);
|
||||
assert_eq!(
|
||||
backend.received.load(Ordering::SeqCst),
|
||||
unselected_count + 1,
|
||||
"session drop must never forward the held packet"
|
||||
);
|
||||
})
|
||||
.await
|
||||
.expect("bounded scope and lifecycle checks");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scoped_ack_queue_expiry_never_forwards_retained_packet() {
|
||||
let (backend, _proxy, queue, caller) = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
let backend = backend().await.expect("backend");
|
||||
let proxy = RpcFaultProxy::start_on("127.0.0.1:0".parse().expect("loopback"), backend.address)
|
||||
.await
|
||||
.expect("proxy");
|
||||
let queue = proxy.scoped_ack_queue(ack_scope(), 1).expect("ACK queue");
|
||||
let client: ProxyClient = Client::builder(TokioExecutor::new()).http2_only(true).build_http();
|
||||
let address = proxy.local_addr();
|
||||
let caller = tokio::spawn(async move {
|
||||
call_ack_frame(&client, address, ack_frame(&ack_request("cold-bucket", 7)), "expires").await
|
||||
});
|
||||
queue.wait_for_packets(1).await.expect("retained packet");
|
||||
(backend, proxy, queue, caller)
|
||||
})
|
||||
.await
|
||||
.expect("bounded queue setup");
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(Duration::from_secs(120)).await;
|
||||
tokio::time::resume();
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_secs(10), caller)
|
||||
.await
|
||||
.expect("expiry completes caller")
|
||||
.expect("caller task")
|
||||
.is_err()
|
||||
);
|
||||
let receipt = queue.receipts().into_iter().next().expect("expired receipt");
|
||||
assert_eq!(receipt["completed"], true);
|
||||
assert_eq!(receipt["forwarded"], false);
|
||||
assert_eq!(receipt["backend_complete"], false);
|
||||
assert_eq!(backend.received.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(
|
||||
queue
|
||||
.settle(0, ScopedAckFault::Forward)
|
||||
.expect_err("expired packet cannot be released")
|
||||
.kind(),
|
||||
io::ErrorKind::TimedOut
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_ack_fault_matches_exact_bucket_and_excludes_probes() {
|
||||
use rustfs_protos::proto_gen::node_service::ScannerScopedDirtyUsageEntry;
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
let backend = backend().await.expect("backend listener");
|
||||
let proxy = RpcFaultProxy::start_on("127.0.0.1:0".parse().expect("loopback"), backend.address)
|
||||
.await
|
||||
.expect("proxy listener");
|
||||
let client: ProxyClient = Client::builder(TokioExecutor::new()).http2_only(true).build_http();
|
||||
let pause = proxy
|
||||
.pause(&["ScannerScopedDirtyUsageAck"], 1, Some("cold-bucket"))
|
||||
.expect("scoped ACK fault");
|
||||
let frame = |bucket: &str, probe_only: bool| {
|
||||
let payload = ScannerScopedDirtyUsageAckRequest {
|
||||
challenge: vec![3; 16].into(),
|
||||
protocol_version: 1,
|
||||
owner_id: "11111111-1111-1111-1111-111111111111".to_string(),
|
||||
instance_id: "a".repeat(32),
|
||||
scope: 1,
|
||||
probe_only,
|
||||
entries: vec![ScannerScopedDirtyUsageEntry {
|
||||
bucket: bucket.to_string(),
|
||||
bucket_incarnation: vec![4; 16].into(),
|
||||
generation: 17,
|
||||
}],
|
||||
}
|
||||
.encode_to_vec();
|
||||
let mut frame = vec![0];
|
||||
frame.extend_from_slice(&u32::try_from(payload.len()).expect("small ACK frame").to_be_bytes());
|
||||
frame.extend_from_slice(&payload);
|
||||
Bytes::from(frame)
|
||||
};
|
||||
for bytes in [frame("cold-bucket", true), frame("cold-bucket-other", false)] {
|
||||
let response = call_body(&client, proxy.local_addr(), "ScannerScopedDirtyUsageAck", bytes.clone())
|
||||
.await
|
||||
.expect("probe and other bucket must progress");
|
||||
assert_eq!(response.into_body().collect().await.expect("unselected response").to_bytes(), bytes);
|
||||
}
|
||||
assert_eq!(pause.hits(), 0);
|
||||
let signed_bytes = frame("cold-bucket", false);
|
||||
let task_bytes = signed_bytes.clone();
|
||||
let address = proxy.local_addr();
|
||||
let blocked =
|
||||
tokio::spawn(async move { call_body(&client, address, "ScannerScopedDirtyUsageAck", task_bytes).await });
|
||||
pause
|
||||
.wait_for_hits(1)
|
||||
.await
|
||||
.expect("real ScannerControlService method must reach the fault");
|
||||
assert!(!blocked.is_finished());
|
||||
assert_eq!(backend.received.load(Ordering::SeqCst), 2, "held ACK must not apply at the backend");
|
||||
pause.release();
|
||||
let collected = blocked
|
||||
.await
|
||||
.expect("ACK task")
|
||||
.expect("released ACK")
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("ACK body and trailers");
|
||||
assert_eq!(collected.trailers().expect("ACK trailers")["x-proof"], "unchanged-proof");
|
||||
assert_eq!(collected.to_bytes(), signed_bytes, "proxy must preserve the complete signed ACK frame");
|
||||
})
|
||||
.await
|
||||
.expect("scanner ACK fault deadline");
|
||||
}
|
||||
|
||||
fn native_abort_request(path: &str) -> RecoverMultipartSourceRequest {
|
||||
use crate::storage_api::multipart_source_rpc::{
|
||||
MultipartAbortCertificate, MultipartAbortProof, MultipartAbortProofKind, MultipartAbortRound, MultipartSourcePhase,
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Bounded retention of original scoped ACK frames, independent of caller cancellation.
|
||||
|
||||
use super::{ProxyBody, ProxyClient, forward_to_backend, unary_payload};
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt as _, Full, Limited};
|
||||
use hyper::{Request, Response};
|
||||
use prost::Message as _;
|
||||
use rustfs_protos::proto_gen::node_service::{ScannerScopedDirtyUsageAckRequest, ScannerScopedDirtyUsageAckResponse};
|
||||
use rustfs_protos::scoped_dirty_usage::validate_scoped_dirty_usage_request;
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
io,
|
||||
net::SocketAddr,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
sync::{Notify, oneshot, watch},
|
||||
task::AbortHandle,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
const MAX_RETAINED: usize = 4;
|
||||
const MAX_BYTES: usize = 1024 * 1024;
|
||||
const RETENTION: Duration = Duration::from_secs(120);
|
||||
|
||||
pub(crate) struct ScopedAckScope {
|
||||
pub nonce: String,
|
||||
pub owner_id: String,
|
||||
pub instance_id: String,
|
||||
pub buckets: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl ScopedAckScope {
|
||||
fn validate(&self) -> io::Result<()> {
|
||||
let valid_uuid = |value: &str| uuid::Uuid::parse_str(value).is_ok_and(|value| !value.is_nil());
|
||||
if !valid_uuid(&self.nonce)
|
||||
|| !valid_uuid(&self.owner_id)
|
||||
|| self.instance_id.is_empty()
|
||||
|| self.instance_id.len() > 128
|
||||
|| self.buckets.is_empty()
|
||||
|| self.buckets.len() > 16
|
||||
|| self.buckets.iter().any(|bucket| bucket.is_empty() || bucket.len() > 63)
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"scoped ACK queue needs a bounded session, receiver and bucket scope",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn matches(&self, request: &ScannerScopedDirtyUsageAckRequest) -> bool {
|
||||
!request.probe_only
|
||||
&& validate_scoped_dirty_usage_request(request).is_ok()
|
||||
&& request.owner_id == self.owner_id
|
||||
&& request.instance_id == self.instance_id
|
||||
&& request.entries.iter().all(|entry| self.buckets.contains(&entry.bucket))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ScopedAckFault {
|
||||
Forward,
|
||||
RequestLoss,
|
||||
ResponseLoss,
|
||||
}
|
||||
|
||||
struct Packet {
|
||||
action: watch::Sender<Option<ScopedAckFault>>,
|
||||
receipt: Mutex<Value>,
|
||||
task: Mutex<Option<AbortHandle>>,
|
||||
}
|
||||
|
||||
pub(super) struct QueueState {
|
||||
scope: ScopedAckScope,
|
||||
capacity: usize,
|
||||
started: Instant,
|
||||
deadline: Instant,
|
||||
closed: AtomicBool,
|
||||
// The packet inventory lock is released before any packet lock or await.
|
||||
packets: Mutex<Vec<Arc<Packet>>>,
|
||||
changed: Notify,
|
||||
}
|
||||
|
||||
pub(super) type QueueSlot = Arc<Mutex<Option<Arc<QueueState>>>>;
|
||||
|
||||
pub(super) fn empty_slot() -> QueueSlot {
|
||||
Arc::new(Mutex::new(None))
|
||||
}
|
||||
|
||||
pub(crate) struct ScopedAckQueue {
|
||||
state: Arc<QueueState>,
|
||||
slot: QueueSlot,
|
||||
}
|
||||
|
||||
impl ScopedAckQueue {
|
||||
pub(super) fn arm(slot: QueueSlot, scope: ScopedAckScope, capacity: usize) -> io::Result<Self> {
|
||||
scope.validate()?;
|
||||
if !(1..=MAX_RETAINED).contains(&capacity) {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "scoped ACK capacity must be 1..=4"));
|
||||
}
|
||||
let mut active = slot.lock().expect("scoped ACK queue slot");
|
||||
if active.is_some() {
|
||||
return Err(io::Error::new(io::ErrorKind::AlreadyExists, "scoped ACK queue is already armed"));
|
||||
}
|
||||
let started = Instant::now();
|
||||
let state = Arc::new(QueueState {
|
||||
scope,
|
||||
capacity,
|
||||
started,
|
||||
deadline: started + RETENTION,
|
||||
closed: AtomicBool::new(false),
|
||||
packets: Mutex::new(Vec::new()),
|
||||
changed: Notify::new(),
|
||||
});
|
||||
*active = Some(state.clone());
|
||||
drop(active);
|
||||
Ok(Self { state, slot })
|
||||
}
|
||||
|
||||
pub(crate) fn receipts(&self) -> Vec<Value> {
|
||||
let packets = self.state.packets.lock().expect("scoped ACK packets").clone();
|
||||
packets
|
||||
.iter()
|
||||
.map(|packet| packet.receipt.lock().expect("scoped ACK receipt").clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_for_packets(&self, count: usize) -> io::Result<()> {
|
||||
self.wait_until(|| self.receipts().len() >= count).await
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_for_completion(&self, id: usize) -> io::Result<Value> {
|
||||
self.wait_until(|| {
|
||||
self.receipts()
|
||||
.get(id)
|
||||
.is_some_and(|receipt| receipt["completed"].as_bool() == Some(true))
|
||||
})
|
||||
.await?;
|
||||
self.receipts()
|
||||
.get(id)
|
||||
.cloned()
|
||||
.ok_or_else(|| io::Error::other("scoped ACK receipt disappeared"))
|
||||
}
|
||||
|
||||
async fn wait_until(&self, ready: impl Fn() -> bool) -> io::Result<()> {
|
||||
tokio::time::timeout_at(self.state.deadline, async {
|
||||
loop {
|
||||
let changed = self.state.changed.notified();
|
||||
if ready() {
|
||||
return;
|
||||
}
|
||||
changed.await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "scoped ACK queue lifetime elapsed"))
|
||||
}
|
||||
|
||||
pub(crate) fn settle(&self, id: usize, fault: ScopedAckFault) -> io::Result<()> {
|
||||
if Instant::now() >= self.state.deadline {
|
||||
return Err(io::Error::new(io::ErrorKind::TimedOut, "scoped ACK retention expired"));
|
||||
}
|
||||
let packet = self
|
||||
.state
|
||||
.packets
|
||||
.lock()
|
||||
.expect("scoped ACK packets")
|
||||
.get(id)
|
||||
.cloned()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "scoped ACK packet is not retained"))?;
|
||||
if !packet.action.send_if_modified(|current| {
|
||||
if current.is_none() {
|
||||
*current = Some(fault);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}) {
|
||||
return Err(io::Error::new(io::ErrorKind::AlreadyExists, "scoped ACK packet was already settled"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ScopedAckQueue {
|
||||
fn drop(&mut self) {
|
||||
self.state.closed.store(true, Ordering::SeqCst);
|
||||
let mut slot = self.slot.lock().expect("scoped ACK queue slot");
|
||||
if slot.as_ref().is_some_and(|current| Arc::ptr_eq(current, &self.state)) {
|
||||
*slot = None;
|
||||
}
|
||||
drop(slot);
|
||||
let packets = self.state.packets.lock().expect("scoped ACK packets").clone();
|
||||
for packet in packets {
|
||||
if let Some(task) = packet.task.lock().expect("scoped ACK task").take() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum Interception {
|
||||
Pass(Request<ProxyBody>),
|
||||
Handled(Response<ProxyBody>),
|
||||
}
|
||||
|
||||
fn restored_body(bytes: Bytes, trailers: Option<http::HeaderMap>) -> ProxyBody {
|
||||
Full::new(bytes)
|
||||
.map_err(|never| match never {})
|
||||
.with_trailers(async move { trailers.map(Ok::<_, io::Error>) })
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn hash(bytes: &[u8]) -> String {
|
||||
hex(Sha256::digest(bytes).as_ref())
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
hex_simd::encode_to_string(bytes, hex_simd::AsciiCase::Lower)
|
||||
}
|
||||
|
||||
fn trailer_receipt(trailers: Option<&http::HeaderMap>) -> Value {
|
||||
trailers.map_or(Value::Null, |headers| {
|
||||
json!(
|
||||
headers
|
||||
.iter()
|
||||
.map(|(name, value)| { json!({"name": name.as_str(), "value_hex": hex(value.as_bytes())}) })
|
||||
.collect::<Vec<_>>()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn header_hash(headers: &http::HeaderMap) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
for (name, value) in headers {
|
||||
hasher.update(name.as_str().as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(value.as_bytes());
|
||||
hasher.update([0]);
|
||||
}
|
||||
hex(hasher.finalize().as_ref())
|
||||
}
|
||||
|
||||
fn millis(state: &QueueState) -> u64 {
|
||||
u64::try_from(state.started.elapsed().as_millis()).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
pub(super) async fn intercept(
|
||||
request: Request<ProxyBody>,
|
||||
client: ProxyClient,
|
||||
target: SocketAddr,
|
||||
state: Arc<QueueState>,
|
||||
) -> io::Result<Interception> {
|
||||
if Instant::now() >= state.deadline || state.closed.load(Ordering::SeqCst) {
|
||||
return Ok(Interception::Pass(request));
|
||||
}
|
||||
let (parts, body) = request.into_parts();
|
||||
let collected = tokio::time::timeout_at(state.deadline, Limited::new(body, MAX_BYTES).collect())
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "scoped ACK frame collection expired"))?
|
||||
.map_err(io::Error::other)?;
|
||||
let trailers = collected.trailers().cloned();
|
||||
let bytes = collected.to_bytes();
|
||||
let decoded = ScannerScopedDirtyUsageAckRequest::decode(unary_payload(&bytes)?).map_err(io::Error::other)?;
|
||||
let request = Request::from_parts(parts, restored_body(bytes.clone(), trailers.clone()));
|
||||
if Instant::now() >= state.deadline || !state.scope.matches(&decoded) {
|
||||
return Ok(Interception::Pass(request));
|
||||
}
|
||||
let header = |name| request.headers().get(name).and_then(|value| value.to_str().ok());
|
||||
let (action, mut selected) = watch::channel(None);
|
||||
let packet = {
|
||||
let mut packets = state.packets.lock().expect("scoped ACK packets");
|
||||
if packets.len() >= state.capacity || state.closed.load(Ordering::SeqCst) {
|
||||
return Ok(Interception::Pass(request));
|
||||
}
|
||||
let id = packets.len();
|
||||
let packet = Arc::new(Packet {
|
||||
action,
|
||||
task: Mutex::new(None),
|
||||
receipt: Mutex::new(json!({
|
||||
"id": id, "session_nonce": state.scope.nonce, "receiver": target.to_string(),
|
||||
"authority": request.uri().authority().map(|value| value.as_str()),
|
||||
"captured_at_ms": millis(&state), "completed": false, "forwarded": false,
|
||||
"backend_complete": false, "outcome": "held", "request_bytes": bytes.len(),
|
||||
"request_sha256": hash(&bytes), "request_frame_hex": hex(&bytes), "request_headers_sha256": header_hash(request.headers()),
|
||||
"request_trailers_sha256": trailers.as_ref().map(header_hash),
|
||||
"request_trailers": trailer_receipt(trailers.as_ref()),
|
||||
"auth_nonce": header("x-rustfs-rpc-nonce"), "replay_nonce": header("x-rustfs-rpc-replay-nonce"),
|
||||
"signature_sha256": header("x-rustfs-rpc-signature-v2").map(|value| hash(value.as_bytes())),
|
||||
"replay_signature_sha256": header("x-rustfs-rpc-signature-v3").map(|value| hash(value.as_bytes())),
|
||||
"request": {"owner_id": decoded.owner_id, "instance_id": decoded.instance_id,
|
||||
"probe_only": decoded.probe_only, "entries": decoded.entries.iter().map(|entry| json!({
|
||||
"bucket": entry.bucket, "bucket_incarnation": uuid::Uuid::from_slice(&entry.bucket_incarnation).ok().map(|value| value.to_string()),
|
||||
"generation": entry.generation })).collect::<Vec<_>>() }
|
||||
})),
|
||||
});
|
||||
packets.push(packet.clone());
|
||||
packet
|
||||
};
|
||||
let (reply, response) = oneshot::channel();
|
||||
let worker_packet = packet.clone();
|
||||
let worker_state = state.clone();
|
||||
// This owner survives an HTTP/2 stream reset from the original 5 s caller.
|
||||
// Every request is settled once and is discarded at the session deadline.
|
||||
let task = tokio::spawn(async move {
|
||||
let result = tokio::time::timeout_at(worker_state.deadline, async {
|
||||
let fault = loop {
|
||||
if let Some(fault) = *selected.borrow_and_update() {
|
||||
break fault;
|
||||
}
|
||||
selected
|
||||
.changed()
|
||||
.await
|
||||
.map_err(|_| io::Error::other("scoped ACK queue closed"))?;
|
||||
};
|
||||
if fault == ScopedAckFault::RequestLoss {
|
||||
worker_packet.receipt.lock().expect("scoped ACK receipt")["outcome"] = json!("request_lost");
|
||||
return Err(io::Error::new(io::ErrorKind::ConnectionAborted, "injected scoped ACK request loss"));
|
||||
}
|
||||
if worker_state.closed.load(Ordering::SeqCst) || Instant::now() >= worker_state.deadline {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::ConnectionAborted,
|
||||
"scoped ACK session ended or expired before forwarding",
|
||||
));
|
||||
}
|
||||
{
|
||||
let mut receipt = worker_packet.receipt.lock().expect("scoped ACK receipt");
|
||||
receipt["forwarded"] = json!(true);
|
||||
receipt["forwarded_at_ms"] = json!(millis(&worker_state));
|
||||
}
|
||||
let backend = forward_to_backend(request, client, target).await?;
|
||||
let (parts, body) = backend.into_parts();
|
||||
let collected = Limited::new(body, MAX_BYTES).collect().await.map_err(io::Error::other)?;
|
||||
let trailers = collected.trailers().cloned();
|
||||
let bytes = collected.to_bytes();
|
||||
let grpc_status = trailers
|
||||
.as_ref()
|
||||
.and_then(|values| values.get("grpc-status"))
|
||||
.or_else(|| parts.headers.get("grpc-status"))
|
||||
.and_then(|value| value.to_str().ok());
|
||||
let response = unary_payload(&bytes)
|
||||
.ok()
|
||||
.and_then(|payload| ScannerScopedDirtyUsageAckResponse::decode(payload).ok())
|
||||
.map(|response| {
|
||||
json!({"owner_id": response.owner_id, "instance_id": response.instance_id,
|
||||
"supported": response.supported, "cleared": response.cleared,
|
||||
"response_proof_bytes": response.response_proof.len()})
|
||||
});
|
||||
{
|
||||
let mut receipt = worker_packet.receipt.lock().expect("scoped ACK receipt");
|
||||
receipt["backend_complete"] = json!(true);
|
||||
receipt["backend_completed_at_ms"] = json!(millis(&worker_state));
|
||||
receipt["response"] = json!({"status": parts.status.as_u16(), "grpc_status": grpc_status,
|
||||
"bytes": bytes.len(), "sha256": hash(&bytes), "frame_hex": hex(&bytes), "headers_sha256": header_hash(&parts.headers),
|
||||
"trailers": trailer_receipt(trailers.as_ref()),
|
||||
"trailers_sha256": trailers.as_ref().map(header_hash), "ack": response});
|
||||
receipt["outcome"] = json!(if fault == ScopedAckFault::ResponseLoss {
|
||||
"response_lost"
|
||||
} else {
|
||||
"forwarded"
|
||||
});
|
||||
}
|
||||
if fault == ScopedAckFault::ResponseLoss {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::ConnectionAborted,
|
||||
"injected scoped ACK response loss after backend trailers",
|
||||
));
|
||||
}
|
||||
Ok(Response::from_parts(parts, restored_body(bytes, trailers)))
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(io::Error::new(io::ErrorKind::TimedOut, "scoped ACK retention expired")));
|
||||
{
|
||||
let mut receipt = worker_packet.receipt.lock().expect("scoped ACK receipt");
|
||||
if result.is_err() && receipt["outcome"] == "held" {
|
||||
receipt["outcome"] = json!("transport_failed_or_expired");
|
||||
}
|
||||
receipt["completed"] = json!(true);
|
||||
receipt["completed_at_ms"] = json!(millis(&worker_state));
|
||||
}
|
||||
let _ = reply.send(result);
|
||||
worker_state.changed.notify_one();
|
||||
});
|
||||
*packet.task.lock().expect("scoped ACK task") = Some(task.abort_handle());
|
||||
if state.closed.load(Ordering::SeqCst) {
|
||||
task.abort();
|
||||
}
|
||||
drop(task);
|
||||
state.changed.notify_one();
|
||||
response
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::ConnectionAborted, "scoped ACK queue ended"))?
|
||||
.map(Interception::Handled)
|
||||
}
|
||||
@@ -80,3 +80,14 @@ pub(crate) mod multipart_source_rpc {
|
||||
PublicationIdentity, SourceGeneration, SourceGeometry, SourceLineage,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod scanner_peer_bucket {
|
||||
use rustfs_ecstore::api::disk as ecstore_disk;
|
||||
use rustfs_ecstore::api::erasure as ecstore_erasure;
|
||||
use rustfs_ecstore::api::rpc as ecstore_rpc;
|
||||
|
||||
pub(crate) use ecstore_disk::{BUCKET_META_PREFIX, RUSTFS_META_BUCKET};
|
||||
pub(crate) use ecstore_erasure::{ReedSolomonEncoder, calc_shard_size};
|
||||
pub(crate) use ecstore_rpc::PeerRestClient;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
e2e-test-hooks = []
|
||||
diagnostic-read-version-caller = ["rustfs-ecstore/diagnostic-read-version-caller"]
|
||||
hotpath = [
|
||||
"hotpath/hotpath",
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed 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.
|
||||
|
||||
//! Test-cluster observations and bounded pauses at real scanner boundaries.
|
||||
//! This module is absent unless `e2e-test-hooks` is explicitly enabled.
|
||||
|
||||
pub(crate) mod worker_observation;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Component, Path};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const MAX_BYTES: u64 = 1024 * 1024;
|
||||
const MAX_EVENTS: u64 = 512;
|
||||
const MAX_WAIT: Duration = Duration::from_secs(120);
|
||||
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Session {
|
||||
nonce: String,
|
||||
buckets: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Arm {
|
||||
nonce: String,
|
||||
stage: String,
|
||||
pid: u32,
|
||||
owner_id: Option<String>,
|
||||
instance_id: Option<String>,
|
||||
epoch: Option<u64>,
|
||||
cycle: Option<u64>,
|
||||
}
|
||||
|
||||
fn valid_component(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 64
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
|
||||
}
|
||||
|
||||
fn read_control<T: serde::de::DeserializeOwned>(path: &Path) -> io::Result<Option<T>> {
|
||||
let metadata = match std::fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if !metadata.is_file() || metadata.len() > MAX_BYTES {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid scanner test control file"));
|
||||
}
|
||||
let data = std::fs::read(path)?;
|
||||
if data.len() > MAX_BYTES as usize {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"scanner test control file grew past its bound",
|
||||
));
|
||||
}
|
||||
serde_json::from_slice(&data).map(Some).map_err(io::Error::other)
|
||||
}
|
||||
|
||||
fn write_observation(directory: &Path, name: &str, bytes: &[u8]) -> io::Result<()> {
|
||||
let temporary = directory.join(format!(".{name}.tmp"));
|
||||
let mut output = std::fs::OpenOptions::new().write(true).create_new(true).open(&temporary)?;
|
||||
output.write_all(bytes)?;
|
||||
output.sync_all()?;
|
||||
std::fs::rename(temporary, directory.join(name))
|
||||
}
|
||||
|
||||
/// Observe a real event and, only for an explicitly armed matching session,
|
||||
/// wait for its release file. No test control can supply scanner state.
|
||||
pub async fn checkpoint(stage: &str, detail: Value) -> io::Result<()> {
|
||||
let Some(directory) = std::env::var_os("RUSTFS_E2E_SCANNER_BARRIER_DIR") else {
|
||||
return Ok(());
|
||||
};
|
||||
let directory = Path::new(&directory);
|
||||
if !directory.is_absolute()
|
||||
|| directory.components().any(|part| matches!(part, Component::ParentDir))
|
||||
|| !valid_component(stage)
|
||||
{
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid scanner test barrier path or stage"));
|
||||
}
|
||||
let Some(session) = read_control::<Session>(&directory.join("session.json"))? else {
|
||||
return Ok(());
|
||||
};
|
||||
if !valid_component(&session.nonce)
|
||||
|| session.buckets.is_empty()
|
||||
|| session.buckets.len() > 32
|
||||
|| session.buckets.iter().any(|bucket| bucket.is_empty() || bucket.len() > 255)
|
||||
{
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid scanner test session"));
|
||||
}
|
||||
// Admission may enable the recorder while this actual start gate is held.
|
||||
// Begin on release, before any snapshot or worker; other boundaries stay synchronous here.
|
||||
if stage != "before_start_snapshot" {
|
||||
worker_observation::boundary(stage, &detail);
|
||||
}
|
||||
if detail
|
||||
.get("bucket")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|bucket| !session.buckets.iter().any(|allowed| allowed == bucket))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let arm = read_control::<Arm>(&directory.join("arm.json"))?;
|
||||
let armed = arm.is_some_and(|arm| {
|
||||
arm.nonce == session.nonce
|
||||
&& arm.stage == stage
|
||||
&& arm.pid == std::process::id()
|
||||
&& arm
|
||||
.owner_id
|
||||
.as_ref()
|
||||
.is_none_or(|owner| detail.get("owner_id").and_then(Value::as_str) == Some(owner.as_str()))
|
||||
&& arm
|
||||
.instance_id
|
||||
.as_ref()
|
||||
.is_none_or(|instance| detail.get("instance_id").and_then(Value::as_str) == Some(instance.as_str()))
|
||||
&& arm
|
||||
.epoch
|
||||
.is_none_or(|epoch| detail.get("epoch").and_then(Value::as_u64) == Some(epoch))
|
||||
&& arm
|
||||
.cycle
|
||||
.is_none_or(|cycle| detail.get("cycle").and_then(Value::as_u64) == Some(cycle))
|
||||
});
|
||||
let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
if sequence >= MAX_EVENTS {
|
||||
return Err(io::Error::other("scanner test observation limit reached"));
|
||||
}
|
||||
let pid = std::process::id();
|
||||
let event = json!({
|
||||
"nonce": session.nonce,
|
||||
"pid": pid,
|
||||
"sequence": sequence,
|
||||
"stage": stage,
|
||||
"armed": armed,
|
||||
"detail": detail,
|
||||
});
|
||||
let encoded = serde_json::to_vec(&event).map_err(io::Error::other)?;
|
||||
if encoded.len() > MAX_BYTES as usize {
|
||||
return Err(io::Error::other("scanner test observation exceeds its byte bound"));
|
||||
}
|
||||
let suffix = format!("{}.{}.{}.json", session.nonce, pid, sequence);
|
||||
write_observation(directory, &format!("observed.{suffix}"), &encoded)?;
|
||||
if !armed {
|
||||
if stage == "before_start_snapshot" {
|
||||
worker_observation::boundary(stage, &event["detail"]);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
write_observation(directory, &format!("reached.{suffix}"), &encoded)?;
|
||||
let release = directory.join(format!("release.{}.{}.{}", session.nonce, stage, pid));
|
||||
let started = Instant::now();
|
||||
while !release.try_exists()? {
|
||||
if started.elapsed() >= MAX_WAIT {
|
||||
return Err(io::Error::new(io::ErrorKind::TimedOut, "scanner test barrier release timed out"));
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
if stage == "before_start_snapshot" {
|
||||
worker_observation::boundary(stage, &event["detail"]);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn hex(bytes: &[u8]) -> String {
|
||||
hex_simd::encode_to_string(bytes, hex_simd::AsciiCase::Lower)
|
||||
}
|
||||
|
||||
pub(crate) fn acknowledgement_observations(acknowledgements: &[crate::scanner::ScannerDirtyUsageAcknowledgement]) -> Value {
|
||||
Value::Array(
|
||||
acknowledgements
|
||||
.iter()
|
||||
.map(|acknowledgement| {
|
||||
use crate::scanner::ScannerDirtyUsageAcknowledgementKind;
|
||||
match &acknowledgement.kind {
|
||||
ScannerDirtyUsageAcknowledgementKind::Generation(generation) => json!({
|
||||
"host": acknowledgement.host,
|
||||
"instance_id": acknowledgement.instance_id,
|
||||
"kind": "generation",
|
||||
"generation": generation,
|
||||
}),
|
||||
ScannerDirtyUsageAcknowledgementKind::Scoped { owner_id, entries } => json!({
|
||||
"host": acknowledgement.host,
|
||||
"owner_id": owner_id,
|
||||
"instance_id": acknowledgement.instance_id,
|
||||
"kind": "scoped",
|
||||
"entries": entries.iter().map(|entry| json!({
|
||||
"bucket": entry.bucket,
|
||||
"bucket_incarnation": entry.bucket_incarnation,
|
||||
"generation": entry.generation,
|
||||
})).collect::<Vec<_>>(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn activity_observations(activity: &crate::scanner::ScannerActivitySnapshot) -> Value {
|
||||
Value::Object(
|
||||
activity
|
||||
.iter()
|
||||
.map(|(host, activity)| (host.clone(), activity.e2e_observation()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
//! N-client evidence only. This recorder never waits for a release or filters buckets.
|
||||
|
||||
use super::{read_control, valid_component, write_observation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::future::Future;
|
||||
use std::io;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use uuid::Uuid;
|
||||
|
||||
const MAX_EVENTS: usize = 512;
|
||||
const MAX_CYCLES: usize = 8;
|
||||
const MAX_BYTES: usize = 1024 * 1024;
|
||||
static OBSERVER: OnceLock<Mutex<Option<Observer>>> = OnceLock::new();
|
||||
tokio::task_local! { static INCARNATION: Option<Uuid>; }
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Control {
|
||||
nonce: String,
|
||||
pid: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Request {
|
||||
context: Value,
|
||||
opening_sequence: usize,
|
||||
complete_sequence: Option<usize>,
|
||||
progress: Option<[u64; 3]>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Cycle {
|
||||
start_sequence: usize,
|
||||
events: Vec<Value>,
|
||||
requests: BTreeMap<String, Request>,
|
||||
local_walks: Vec<Value>,
|
||||
candidate: Option<Value>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
struct Observer {
|
||||
directory: PathBuf,
|
||||
nonce: String,
|
||||
pid: u32,
|
||||
sequence: usize,
|
||||
errors: BTreeSet<&'static str>,
|
||||
cycles: BTreeMap<(u64, u64), Cycle>,
|
||||
manifests: Vec<Value>,
|
||||
}
|
||||
|
||||
fn invalid(code: &'static str) -> io::Error {
|
||||
io::Error::other(code)
|
||||
}
|
||||
|
||||
fn cycle_key(detail: &Value) -> io::Result<(u64, u64)> {
|
||||
Ok((
|
||||
detail["epoch"].as_u64().ok_or_else(|| invalid("malformed"))?,
|
||||
detail["cycle"].as_u64().ok_or_else(|| invalid("malformed"))?,
|
||||
))
|
||||
}
|
||||
|
||||
fn uuid_field(detail: &Value, name: &str) -> io::Result<()> {
|
||||
let value = detail[name].as_str().ok_or_else(|| invalid("malformed"))?;
|
||||
if Uuid::parse_str(value).is_ok_and(|id| !id.is_nil()) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(invalid("malformed"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Observer {
|
||||
fn new(directory: PathBuf, control: Control) -> Self {
|
||||
Self {
|
||||
directory,
|
||||
nonce: control.nonce,
|
||||
pid: control.pid,
|
||||
sequence: 0,
|
||||
errors: BTreeSet::new(),
|
||||
cycles: BTreeMap::new(),
|
||||
manifests: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn health(&self) -> Value {
|
||||
json!({"schema": 1, "nonce": self.nonce, "pid": self.pid, "event_count": self.sequence,
|
||||
"max_events": MAX_EVENTS, "max_cycles": MAX_CYCLES, "max_bytes": MAX_BYTES,
|
||||
"errors": self.errors, "manifests": self.manifests})
|
||||
}
|
||||
|
||||
fn persist(&self, name: &str, value: &Value) -> io::Result<String> {
|
||||
let bytes = serde_json::to_vec(value)?;
|
||||
if bytes.len() > MAX_BYTES {
|
||||
return Err(invalid("overflow"));
|
||||
}
|
||||
write_observation(&self.directory, name, &bytes)?;
|
||||
Ok(super::hex(&Sha256::digest(&bytes)))
|
||||
}
|
||||
|
||||
fn event(&mut self, key: (u64, u64), kind: &str, detail: Value) -> io::Result<usize> {
|
||||
if self.sequence >= MAX_EVENTS {
|
||||
return Err(invalid("overflow"));
|
||||
}
|
||||
let sequence = self.sequence;
|
||||
self.sequence += 1;
|
||||
let event = json!({"schema": 1, "nonce": self.nonce, "pid": self.pid,
|
||||
"sequence": sequence, "kind": kind, "epoch": key.0, "cycle": key.1, "detail": detail});
|
||||
let name = format!("worker-observed.{}.{}.{sequence}.json", self.nonce, self.pid);
|
||||
let sha256 = self.persist(&name, &event)?;
|
||||
self.cycles
|
||||
.get_mut(&key)
|
||||
.ok_or_else(|| invalid("outside_cycle"))?
|
||||
.events
|
||||
.push(json!({"file": name, "sha256": sha256, "sequence": sequence, "kind": kind}));
|
||||
Ok(sequence)
|
||||
}
|
||||
|
||||
fn active(&self, key: (u64, u64)) -> io::Result<()> {
|
||||
match self.cycles.get(&key) {
|
||||
Some(cycle) if !cycle.closed => Ok(()),
|
||||
Some(_) => Err(invalid("late")),
|
||||
None => Err(invalid("outside_cycle")),
|
||||
}
|
||||
}
|
||||
|
||||
fn boundary(&mut self, stage: &str, detail: Value) -> io::Result<()> {
|
||||
let key = cycle_key(&detail)?;
|
||||
if stage == "before_start_snapshot" {
|
||||
if self.cycles.len() >= MAX_CYCLES {
|
||||
return Err(invalid("overflow"));
|
||||
}
|
||||
if self.cycles.contains_key(&key) {
|
||||
return Err(invalid("duplicate"));
|
||||
}
|
||||
if self.cycles.values().any(|cycle| !cycle.closed) {
|
||||
return Err(invalid("cross_cycle"));
|
||||
}
|
||||
self.cycles.insert(
|
||||
key,
|
||||
Cycle {
|
||||
start_sequence: self.sequence,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
self.active(key)?;
|
||||
match stage {
|
||||
"before_start_snapshot" => {
|
||||
self.event(key, "cycle_begin", detail)?;
|
||||
}
|
||||
"bucket_walk" => {
|
||||
if self.cycles[&key].candidate.is_some() {
|
||||
return Err(invalid("late"));
|
||||
}
|
||||
uuid_field(&detail, "bucket_incarnation")?;
|
||||
uuid_field(&detail, "disk_uuid")?;
|
||||
let sequence = self.event(key, "local_walk", detail.clone())?;
|
||||
self.cycles
|
||||
.get_mut(&key)
|
||||
.ok_or_else(|| invalid("outside_cycle"))?
|
||||
.local_walks
|
||||
.push(json!({"sequence": sequence, "context": detail}));
|
||||
}
|
||||
"cycle_candidate" => {
|
||||
if self.cycles[&key].candidate.is_some() {
|
||||
return Err(invalid("duplicate"));
|
||||
}
|
||||
self.event(key, "candidate", detail.clone())?;
|
||||
self.cycles.get_mut(&key).ok_or_else(|| invalid("outside_cycle"))?.candidate = Some(detail);
|
||||
}
|
||||
"root_durable" => self.close(key, detail)?,
|
||||
_ => return Err(invalid("malformed")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn opening(&mut self, mut context: Value, incarnation: Option<Uuid>) -> io::Result<()> {
|
||||
let key = cycle_key(&context)?;
|
||||
// A removed session ends the measurement window, but never hides a late event for a known cycle.
|
||||
if !self.cycles.contains_key(&key) && !self.directory.join("session.json").try_exists()? {
|
||||
return Ok(());
|
||||
}
|
||||
self.active(key)?;
|
||||
if self.cycles[&key].candidate.is_some() {
|
||||
return Err(invalid("late"));
|
||||
}
|
||||
context["bucket_incarnation"] = json!(incarnation);
|
||||
for field in [
|
||||
"request_id",
|
||||
"wire_session_id",
|
||||
"server_epoch",
|
||||
"bucket_incarnation",
|
||||
"disk_uuid",
|
||||
] {
|
||||
uuid_field(&context, field)?;
|
||||
}
|
||||
for field in ["pool", "set", "disk_index", "wire_session_sequence"] {
|
||||
context[field].as_u64().ok_or_else(|| invalid("malformed"))?;
|
||||
}
|
||||
for field in [
|
||||
"bucket",
|
||||
"worker_endpoint",
|
||||
"worker_path",
|
||||
"reuse_plan_sha256",
|
||||
"coverage_plan_sha256",
|
||||
] {
|
||||
if context[field].as_str().is_none_or(str::is_empty) {
|
||||
return Err(invalid("malformed"));
|
||||
}
|
||||
}
|
||||
for field in ["reuse_plan_sha256", "coverage_plan_sha256"] {
|
||||
if context[field]
|
||||
.as_str()
|
||||
.is_none_or(|value| value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()))
|
||||
{
|
||||
return Err(invalid("malformed"));
|
||||
}
|
||||
}
|
||||
let id = context["request_id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| invalid("malformed"))?
|
||||
.to_string();
|
||||
if self.cycles.values().any(|cycle| cycle.requests.contains_key(&id)) {
|
||||
return Err(invalid("duplicate"));
|
||||
}
|
||||
let sequence = self.event(key, "remote_scan_opening", context.clone())?;
|
||||
self.cycles
|
||||
.get_mut(&key)
|
||||
.ok_or_else(|| invalid("outside_cycle"))?
|
||||
.requests
|
||||
.insert(
|
||||
id,
|
||||
Request {
|
||||
context,
|
||||
opening_sequence: sequence,
|
||||
complete_sequence: None,
|
||||
progress: None,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn complete(&mut self, id: Uuid, progress: [u64; 3]) -> io::Result<()> {
|
||||
let id = id.to_string();
|
||||
let key = self
|
||||
.cycles
|
||||
.iter()
|
||||
.find_map(|(key, cycle)| cycle.requests.contains_key(&id).then_some(*key));
|
||||
let Some(key) = key else {
|
||||
if !self.directory.join("session.json").try_exists()? {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(invalid("unpaired"));
|
||||
};
|
||||
self.active(key)?;
|
||||
if self.cycles[&key].candidate.is_some() {
|
||||
return Err(invalid("late"));
|
||||
}
|
||||
let request = &self.cycles[&key].requests[&id];
|
||||
if request.complete_sequence.is_some() {
|
||||
return Err(invalid("duplicate"));
|
||||
}
|
||||
let detail = json!({"request_id": id, "context": request.context, "progress": progress,
|
||||
"eof_verified": true, "mac_source_plan_verified": true});
|
||||
let sequence = self.event(key, "remote_scan_complete", detail)?;
|
||||
let request = self
|
||||
.cycles
|
||||
.get_mut(&key)
|
||||
.and_then(|cycle| cycle.requests.get_mut(&id))
|
||||
.ok_or_else(|| invalid("unpaired"))?;
|
||||
request.complete_sequence = Some(sequence);
|
||||
request.progress = Some(progress);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Hooks are synchronous; all worker/set tasks join before candidate/root.
|
||||
// A closed manifest is per-cycle evidence, not a continuous live-health attestation.
|
||||
fn close(&mut self, key: (u64, u64), root: Value) -> io::Result<()> {
|
||||
if !self.errors.is_empty() {
|
||||
return Err(invalid("unhealthy"));
|
||||
}
|
||||
let cycle = &self.cycles[&key];
|
||||
let candidate = cycle.candidate.as_ref().ok_or_else(|| invalid("unpaired"))?;
|
||||
for field in ["candidate_sha256", "coverage_sha256"] {
|
||||
if root[field].as_str().is_none() || root[field] != candidate[field] {
|
||||
return Err(invalid("coverage"));
|
||||
}
|
||||
}
|
||||
let mut observed = BTreeSet::new();
|
||||
for context in cycle
|
||||
.requests
|
||||
.values()
|
||||
.map(|request| &request.context)
|
||||
.chain(cycle.local_walks.iter().map(|walk| &walk["context"]))
|
||||
{
|
||||
let pool = context["pool"].as_u64().ok_or_else(|| invalid("malformed"))?;
|
||||
let set = context["set"].as_u64().ok_or_else(|| invalid("malformed"))?;
|
||||
let bucket = context["bucket"].as_str().ok_or_else(|| invalid("malformed"))?;
|
||||
if !observed.insert((pool, set, bucket)) {
|
||||
return Err(invalid("duplicate"));
|
||||
}
|
||||
let coverage = candidate["set_coverage"].as_array().ok_or_else(|| invalid("coverage"))?;
|
||||
if !coverage.iter().any(|row| {
|
||||
row["pool"].as_u64() == Some(pool)
|
||||
&& row["set"].as_u64() == Some(set)
|
||||
&& row["bucket_incarnations"][bucket] == context["bucket_incarnation"]
|
||||
&& !context["bucket_incarnation"].is_null()
|
||||
}) {
|
||||
return Err(invalid("coverage"));
|
||||
}
|
||||
}
|
||||
if cycle.requests.values().any(|request| request.complete_sequence.is_none()) {
|
||||
return Err(invalid("unpaired"));
|
||||
}
|
||||
let sequence = self.event(key, "cycle_close", root.clone())?;
|
||||
self.cycles.get_mut(&key).ok_or_else(|| invalid("outside_cycle"))?.closed = true;
|
||||
let cycle = &self.cycles[&key];
|
||||
let manifest = json!({"schema": 1, "nonce": self.nonce, "pid": self.pid, "epoch": key.0, "cycle": key.1,
|
||||
"start_sequence": cycle.start_sequence, "close_sequence": sequence, "events": cycle.events,
|
||||
"requests": cycle.requests, "local_walks": cycle.local_walks, "candidate": cycle.candidate,
|
||||
"root": root, "errors": self.errors, "outstanding": 0, "closed": true});
|
||||
let name = format!("worker-manifest.{}.{}.{}.{}.json", self.nonce, self.pid, key.0, key.1);
|
||||
let sha256 = self.persist(&name, &manifest)?;
|
||||
self.manifests
|
||||
.push(json!({"file": name, "sha256": sha256, "epoch": key.0, "cycle": key.1,
|
||||
"close_sequence": sequence}));
|
||||
Ok(())
|
||||
}
|
||||
fn apply(&mut self, action: impl FnOnce(&mut Self) -> io::Result<()>) {
|
||||
if let Err(error) = action(self) {
|
||||
let code = match error.to_string().as_str() {
|
||||
"overflow" => "overflow",
|
||||
"late" => "late",
|
||||
"duplicate" => "duplicate",
|
||||
"coverage" => "coverage",
|
||||
"unpaired" => "unpaired",
|
||||
"outside_cycle" => "outside_cycle",
|
||||
"cross_cycle" => "cross_cycle",
|
||||
_ => "io_or_malformed",
|
||||
};
|
||||
self.errors.insert(code);
|
||||
}
|
||||
let name = format!("worker-health.{}.{}.json", self.nonce, self.pid);
|
||||
if self.persist(&name, &self.health()).is_err() {
|
||||
self.errors.insert("health_io");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn control(directory: &Path) -> io::Result<Option<Control>> {
|
||||
if !directory.is_absolute() || directory.components().any(|part| matches!(part, Component::ParentDir)) {
|
||||
return Err(invalid("malformed"));
|
||||
}
|
||||
let control = read_control::<Control>(&directory.join("worker-observer.json"))?;
|
||||
if control
|
||||
.as_ref()
|
||||
.is_some_and(|control| !valid_component(&control.nonce) || control.pid == 0)
|
||||
{
|
||||
return Err(invalid("malformed"));
|
||||
}
|
||||
Ok(control)
|
||||
}
|
||||
|
||||
/// The scope covers the normal open and consume future; it never consults O again.
|
||||
pub(crate) async fn with_incarnation<F: Future>(incarnation: Option<Uuid>, future: F) -> F::Output {
|
||||
INCARNATION.scope(incarnation, future).await
|
||||
}
|
||||
|
||||
pub(crate) fn enabled() -> bool {
|
||||
// Once begun, a transient control read failure must never hide a real opening or completion.
|
||||
if OBSERVER.get().is_some_and(|state| {
|
||||
state
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner())
|
||||
.as_ref()
|
||||
.is_some_and(|observer| observer.pid == std::process::id())
|
||||
}) {
|
||||
return true;
|
||||
}
|
||||
let Some(directory) = std::env::var_os("RUSTFS_E2E_SCANNER_BARRIER_DIR") else { return false };
|
||||
control(Path::new(&directory))
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some_and(|control| control.pid == std::process::id())
|
||||
}
|
||||
|
||||
fn update(action: impl FnOnce(&mut Observer) -> io::Result<()>) {
|
||||
let Some(directory) = std::env::var_os("RUSTFS_E2E_SCANNER_BARRIER_DIR") else { return };
|
||||
let directory = PathBuf::from(directory);
|
||||
let (mut guard, poisoned) = match OBSERVER.get_or_init(|| Mutex::new(None)).lock() {
|
||||
Ok(guard) => (guard, false),
|
||||
Err(poison) => (poison.into_inner(), true),
|
||||
};
|
||||
let configured = control(&directory);
|
||||
match configured {
|
||||
Ok(Some(control)) if control.pid == std::process::id() => {
|
||||
if let Some(observer) = guard.as_mut() {
|
||||
if observer.nonce != control.nonce {
|
||||
observer.errors.insert("session_changed");
|
||||
}
|
||||
} else {
|
||||
*guard = Some(Observer::new(directory, control));
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
let Some(observer) = guard.as_mut() else { return };
|
||||
observer.errors.insert("control_changed");
|
||||
}
|
||||
Err(_) => {
|
||||
if let Some(observer) = guard.as_mut() {
|
||||
observer.errors.insert("control_io");
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(observer) = guard.as_mut() else { return };
|
||||
if poisoned {
|
||||
observer.errors.insert("poisoned");
|
||||
}
|
||||
observer.apply(action);
|
||||
}
|
||||
|
||||
pub(crate) fn boundary(stage: &str, detail: &Value) {
|
||||
if !matches!(stage, "before_start_snapshot" | "cycle_candidate" | "root_durable" | "bucket_walk") {
|
||||
return;
|
||||
}
|
||||
if stage == "bucket_walk" && detail["worker_kind"] != "local" {
|
||||
return;
|
||||
}
|
||||
update(|observer| observer.boundary(stage, detail.clone()));
|
||||
}
|
||||
|
||||
pub(crate) fn opening(context: Value) {
|
||||
let incarnation = INCARNATION.try_with(|incarnation| *incarnation).ok().flatten();
|
||||
update(|observer| observer.opening(context, incarnation));
|
||||
}
|
||||
|
||||
/// Called only after the ordinary client's authenticated terminal EOF validation.
|
||||
pub(crate) fn complete(id: Uuid, progress: [u64; 3]) {
|
||||
update(|observer| observer.complete(id, progress));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn observer(path: &Path) -> Observer {
|
||||
std::fs::write(path.join("session.json"), b"{}").expect("active observation window");
|
||||
Observer::new(
|
||||
path.to_path_buf(),
|
||||
Control {
|
||||
nonce: "unit".into(),
|
||||
pid: 1,
|
||||
},
|
||||
)
|
||||
}
|
||||
fn context(id: Uuid, cycle: u64) -> Value {
|
||||
json!({"epoch": 1, "cycle": cycle, "request_id": id, "wire_session_id": Uuid::new_v4(),
|
||||
"server_epoch": Uuid::new_v4(), "wire_session_sequence": 0, "pool": 0, "set": 0,
|
||||
"disk_index": 0, "disk_uuid": Uuid::new_v4(), "bucket": "hot", "worker_endpoint": "http://worker:9000",
|
||||
"worker_path": "/disk", "reuse_plan_sha256": "a".repeat(64), "coverage_plan_sha256": "b".repeat(64)})
|
||||
}
|
||||
fn candidate(incarnation: Uuid) -> Value {
|
||||
json!({"epoch":1,"cycle":2,"candidate_sha256":"c".repeat(64),"coverage_sha256":"d".repeat(64),
|
||||
"set_coverage":[{"pool":0,"set":0,"bucket_incarnations":{"hot":incarnation}}]})
|
||||
}
|
||||
fn root() -> Value {
|
||||
json!({"epoch":1,"cycle":2,"candidate_sha256":"c".repeat(64),"coverage_sha256":"d".repeat(64),
|
||||
"root_sha256":"e".repeat(64),"root_version":"root"})
|
||||
}
|
||||
#[test]
|
||||
fn worker_inventory_rejects_unpaired_partial_and_late_completions() {
|
||||
let dir = tempfile::tempdir().expect("temporary evidence directory");
|
||||
let mut observer = observer(dir.path());
|
||||
observer
|
||||
.boundary("before_start_snapshot", json!({"epoch":1,"cycle":2}))
|
||||
.expect("begin");
|
||||
let id = Uuid::new_v4();
|
||||
let incarnation = Uuid::new_v4();
|
||||
observer.opening(context(id, 2), Some(incarnation)).expect("opening");
|
||||
observer
|
||||
.boundary("cycle_candidate", candidate(incarnation))
|
||||
.expect("candidate");
|
||||
assert_eq!(
|
||||
observer
|
||||
.close((1, 2), root())
|
||||
.expect_err("Partial/MAC/EOF failure cannot pair opening")
|
||||
.to_string(),
|
||||
"unpaired"
|
||||
);
|
||||
observer.apply(|state| state.complete(id, [1, 0, 0]));
|
||||
assert!(observer.errors.contains("late"));
|
||||
assert!(observer.close((1, 2), root()).is_err());
|
||||
assert!(observer.complete(Uuid::new_v4(), [1, 0, 0]).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn worker_inventory_preserves_zero_progress_and_rejects_cross_cycle_and_overflow() {
|
||||
let dir = tempfile::tempdir().expect("temporary evidence directory");
|
||||
let mut observer = observer(dir.path());
|
||||
observer
|
||||
.boundary("before_start_snapshot", json!({"epoch":1,"cycle":2}))
|
||||
.expect("begin");
|
||||
let id = Uuid::new_v4();
|
||||
assert!(observer.opening(context(id, 3), Some(Uuid::new_v4())).is_err());
|
||||
observer.opening(context(id, 2), Some(Uuid::new_v4())).expect("opening");
|
||||
observer
|
||||
.complete(id, [0, 0, 0])
|
||||
.expect("verified Current may make no progress");
|
||||
assert_eq!(observer.cycles[&(1, 2)].requests[&id.to_string()].progress, Some([0, 0, 0]));
|
||||
let incarnation = observer.cycles[&(1, 2)].requests[&id.to_string()].context["bucket_incarnation"]
|
||||
.as_str()
|
||||
.expect("captured incarnation")
|
||||
.parse()
|
||||
.expect("UUID");
|
||||
observer
|
||||
.boundary("cycle_candidate", candidate(incarnation))
|
||||
.expect("candidate");
|
||||
observer
|
||||
.close((1, 2), root())
|
||||
.expect("zero-progress Current is recorded, never silently treated as a walk");
|
||||
observer.apply(|state| state.complete(id, [1, 0, 0]));
|
||||
assert!(observer.errors.contains("late"));
|
||||
// Reserve exhaustion on a new active cycle; closed-cycle rejection must not mask overflow.
|
||||
observer
|
||||
.boundary("before_start_snapshot", json!({"epoch":1,"cycle":3}))
|
||||
.expect("second begin");
|
||||
observer.sequence = MAX_EVENTS;
|
||||
observer.apply(|state| state.opening(context(Uuid::new_v4(), 3), Some(Uuid::new_v4())));
|
||||
assert!(observer.errors.contains("overflow"));
|
||||
}
|
||||
#[test]
|
||||
fn worker_inventory_write_failure_and_missing_incarnation_cannot_close() {
|
||||
let dir = tempfile::tempdir().expect("temporary evidence directory");
|
||||
let mut observer = observer(dir.path());
|
||||
observer
|
||||
.boundary("before_start_snapshot", json!({"epoch":1,"cycle":2}))
|
||||
.expect("begin");
|
||||
observer.apply(|state| state.opening(context(Uuid::new_v4(), 2), None));
|
||||
assert!(observer.errors.contains("io_or_malformed"));
|
||||
std::fs::remove_dir_all(dir.path()).expect("remove evidence destination");
|
||||
observer.apply(|state| state.opening(context(Uuid::new_v4(), 2), Some(Uuid::new_v4())));
|
||||
assert!(observer.errors.contains("health_io"));
|
||||
assert_eq!(
|
||||
observer
|
||||
.close((1, 2), root())
|
||||
.expect_err("sticky recorder failure prevents closure")
|
||||
.to_string(),
|
||||
"unhealthy"
|
||||
);
|
||||
assert!(observer.manifests.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,9 @@ use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub mod data_usage_define;
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
#[doc(hidden)]
|
||||
pub mod e2e_barrier;
|
||||
pub mod error;
|
||||
mod heal_completion;
|
||||
pub mod prefix_usage;
|
||||
|
||||
@@ -792,6 +792,16 @@ async fn scan_and_persist_local_bucket(
|
||||
let set_disks = scanner_set_disk_inventory(set.as_ref()).await;
|
||||
let (checkpoint_tx, mut checkpoint_rx) = mpsc::channel::<DataUsageCache>(1);
|
||||
let scan_ctx = ctx.child_token();
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
let _ = crate::e2e_barrier::checkpoint(
|
||||
"bucket_walk",
|
||||
serde_json::json!({
|
||||
"epoch": leader_epoch, "cycle": next_cycle,
|
||||
"pool": source.pool_index, "set": source.set_index,
|
||||
"bucket": bucket, "drive": disk.path().display().to_string(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let scan = ScannerIODisk::nsscanner_disk(
|
||||
disk.clone(),
|
||||
scan_ctx.clone(),
|
||||
@@ -1052,6 +1062,25 @@ pub(crate) async fn scan_remote_bucket(
|
||||
let result = async {
|
||||
let rpc_deadline = Instant::now() + NS_SCANNER_MAX_RPC_LIFETIME;
|
||||
let open_deadline = (Instant::now() + NS_SCANNER_STALL_TIMEOUT).min(rpc_deadline);
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
if crate::e2e_barrier::worker_observation::enabled() {
|
||||
let location = disk.get_disk_location();
|
||||
// RemoteDisk::get_disk_id reads the same cached ID used by disk_ref; no RPC.
|
||||
let disk_uuid = crate::storage_api::owner::EcstoreDiskAPI::get_disk_id(disk)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
crate::e2e_barrier::worker_observation::opening(serde_json::json!({
|
||||
"epoch": leader_epoch, "cycle": next_cycle, "request_id": request_id,
|
||||
"wire_session_id": session_id, "wire_session_sequence": session_sequence,
|
||||
"server_epoch": server_epoch, "bucket": bucket, "pool": expected_source.pool_index,
|
||||
"set": expected_source.set_index, "disk_index": location.disk_idx, "disk_uuid": disk_uuid,
|
||||
"worker_endpoint": crate::storage_api::owner::EcstoreDiskAPI::endpoint(disk).grid_host(),
|
||||
"worker_path": disk.path().display().to_string(),
|
||||
"reuse_plan_sha256": crate::e2e_barrier::hex(&scan_plan_digest.0),
|
||||
"coverage_plan_sha256": crate::e2e_barrier::hex(&checkpoint_plan_digest.0),
|
||||
}));
|
||||
}
|
||||
let open_stream = disk.open_ns_scanner_stream(crate::NsScannerOpenRequest {
|
||||
request_id,
|
||||
server_epoch,
|
||||
@@ -1387,6 +1416,17 @@ where
|
||||
last_phase,
|
||||
));
|
||||
}
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
if matches!(&result, Ok(RemoteScannerOutcome::Complete { .. })) && crate::e2e_barrier::worker_observation::enabled() {
|
||||
crate::e2e_barrier::worker_observation::complete(
|
||||
authenticator.request_id,
|
||||
[
|
||||
last_progress.objects_scanned,
|
||||
last_progress.directories_started,
|
||||
last_progress.entries_visited,
|
||||
],
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2024,6 +2024,8 @@ where
|
||||
resolved_scope_observer: None,
|
||||
#[cfg(test)]
|
||||
post_walk_gate: None,
|
||||
#[cfg(test)]
|
||||
peer_observations: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -2164,6 +2166,9 @@ where
|
||||
scanner_post_lease_activity_defer_reason(
|
||||
result.activity_baseline(),
|
||||
probe_scanner_activity(storeapi.as_ref(), true).await,
|
||||
result
|
||||
.remote_dirty_usage_certification()
|
||||
.zip(remote_publication_leases.as_ref().map(|(_, grants)| grants.as_slice())),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
@@ -2515,9 +2520,24 @@ where
|
||||
} else {
|
||||
"incomplete_coverage"
|
||||
};
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
let durable_observation = scan_cycle_result
|
||||
.publication_expectation()
|
||||
.as_ref()
|
||||
.and_then(|expected| usage_publication_result.e2e_durable_observation(leader_epoch, cycle_info.current, expected));
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
if let Some(observation) = &durable_observation {
|
||||
let _ = crate::e2e_barrier::checkpoint("root_durable", observation.clone()).await;
|
||||
}
|
||||
let pending_dirty_usage_buckets = scan_cycle_result.pending_dirty_usage_buckets();
|
||||
let (completion_outcome, scanner_pending_maintenance_work, remote_dirty_usage_acknowledgements) =
|
||||
finalize_scanner_cycle_result(scan_cycle_result, usage_publication_result);
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
if let Some(mut observation) = durable_observation {
|
||||
observation["acks"] = crate::e2e_barrier::acknowledgement_observations(&remote_dirty_usage_acknowledgements);
|
||||
observation["pending_bucket_count"] = serde_json::json!(pending_dirty_usage_buckets);
|
||||
let _ = crate::e2e_barrier::checkpoint("ack_send", observation).await;
|
||||
}
|
||||
let remote_dirty_usage_pending = if remote_dirty_usage_acknowledgements.is_empty() {
|
||||
false
|
||||
} else if let Some(notification_system) = storeapi.scanner_notification_system() {
|
||||
@@ -3949,18 +3969,26 @@ impl ScannerCyclePublicationPlan {
|
||||
/// Re-prove the guardrail after every peer granted a publication lease. The
|
||||
/// comparison uses the guardrail fields (`scanner_activity_guardrail_veto`),
|
||||
/// not the full digest: local generation movement in this window is the next
|
||||
/// cycle's work and its buckets stay in the dirty map. A peer whose dirty
|
||||
/// usage moved keeps deferring in this phase, as it does during the walk.
|
||||
/// cycle's work and its buckets stay in the dirty map. A sealed producer
|
||||
/// proof may cover peer dirty movement too, but must bind every lease owner
|
||||
/// and session without changing its candidate or acknowledged tuples.
|
||||
fn scanner_post_lease_activity_defer_reason(
|
||||
baseline: Option<&ScannerActivitySnapshot>,
|
||||
activity: Result<ScannerActivitySnapshot, String>,
|
||||
certification: Option<(
|
||||
&crate::scanner_io::ScannerRemoteDirtyUsageCertification,
|
||||
&[crate::storage_api::EcstoreScannerPublicationLeaseGrant],
|
||||
)>,
|
||||
) -> Option<ScannerCycleDeferReason> {
|
||||
match activity {
|
||||
Ok(snapshot)
|
||||
if scanner_activity_allows_usage_publication(&snapshot)
|
||||
&& baseline.is_some_and(|baseline| {
|
||||
scanner_activity_guardrail_veto(baseline, &snapshot).is_none()
|
||||
&& scanner_peer_dirty_usage_moved(baseline, &snapshot).is_none()
|
||||
&& certification.map_or_else(
|
||||
|| scanner_peer_dirty_usage_moved(baseline, &snapshot).is_none(),
|
||||
|(proof, grants)| proof.matches_publication_leases(grants) && proof.matches_activity(&snapshot),
|
||||
)
|
||||
}) =>
|
||||
{
|
||||
None
|
||||
@@ -3970,6 +3998,13 @@ fn scanner_post_lease_activity_defer_reason(
|
||||
Err(_) => "probe_failed",
|
||||
Ok(snapshot) if !scanner_activity_allows_usage_publication(snapshot) => "publication_blocked",
|
||||
Ok(_) if baseline.is_none() => "baseline_missing",
|
||||
Ok(snapshot)
|
||||
if certification.is_some_and(|(proof, grants)| {
|
||||
!proof.matches_publication_leases(grants) || !proof.matches_activity(snapshot)
|
||||
}) =>
|
||||
{
|
||||
"peer_certification"
|
||||
}
|
||||
Ok(snapshot) => baseline
|
||||
.and_then(|baseline| {
|
||||
scanner_activity_guardrail_veto(baseline, snapshot)
|
||||
@@ -4133,6 +4168,7 @@ fn finalize_scanner_cycle_result(
|
||||
.is_some_and(|proof| proof.verified_version_for(expected).is_some())
|
||||
});
|
||||
let pending_maintenance_work = scan_cycle_result.has_pending_maintenance_work()
|
||||
|| scan_cycle_result.has_remote_dirty_usage_pending()
|
||||
|| (scan_cycle_result.has_dirty_usage_to_acknowledge() && !durable_complete_snapshot);
|
||||
let remote_dirty_usage_acknowledgements = if durable_complete_snapshot {
|
||||
match proof {
|
||||
|
||||
@@ -546,6 +546,24 @@ pub(crate) struct ScannerNodeActivity {
|
||||
pub(super) publication_blocked: bool,
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
impl ScannerNodeActivity {
|
||||
pub(crate) fn e2e_observation(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"instance_id": self.instance_id,
|
||||
"namespace_generation": self.namespace_generation,
|
||||
"maintenance_generation": self.maintenance_generation,
|
||||
"protocol_version": self.protocol_version,
|
||||
"topology_digest": crate::e2e_barrier::hex(&self.topology_digest),
|
||||
"data_movement_active": self.data_movement_active,
|
||||
"dirty_usage_generation": self.dirty_usage_generation,
|
||||
"dirty_usage_pending": self.dirty_usage_pending,
|
||||
"movement_generation": self.movement_generation,
|
||||
"publication_blocked": self.publication_blocked,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) type ScannerActivitySnapshot = BTreeMap<String, ScannerNodeActivity>;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -10362,7 +10362,7 @@ fn post_lease_activity_proof_rejects_a_peer_whose_dirty_usage_moved_before_lease
|
||||
(LOCAL_SCANNER_ACTIVITY_NODE.to_string(), scanner_node_activity("local", 5, 2)),
|
||||
("node-2".to_string(), scanner_node_activity("epoch-a", 7, 3)),
|
||||
]);
|
||||
assert_eq!(scanner_post_lease_activity_defer_reason(Some(&before), Ok(before.clone())), None);
|
||||
assert_eq!(scanner_post_lease_activity_defer_reason(Some(&before), Ok(before.clone()), None), None);
|
||||
|
||||
// Local generation movement in the lease window is the next cycle's
|
||||
// work: its buckets stay in the dirty map and the walk remains certified.
|
||||
@@ -10373,7 +10373,7 @@ fn post_lease_activity_proof_rejects_a_peer_whose_dirty_usage_moved_before_lease
|
||||
local.namespace_generation += 1;
|
||||
local.dirty_usage_generation += 1;
|
||||
local.dirty_usage_pending = true;
|
||||
assert_eq!(scanner_post_lease_activity_defer_reason(Some(&before), Ok(local_shaded)), None);
|
||||
assert_eq!(scanner_post_lease_activity_defer_reason(Some(&before), Ok(local_shaded), None), None);
|
||||
|
||||
// A peer PUT tail that drained before the lease leaves the movement
|
||||
// generation accepted by the lease unchanged, but the peer's dirty state
|
||||
@@ -10387,14 +10387,14 @@ fn post_lease_activity_proof_rejects_a_peer_whose_dirty_usage_moved_before_lease
|
||||
"the existing movement-only lease remains valid after a PUT tail drains"
|
||||
);
|
||||
assert!(scanner_activity_allows_usage_publication(&after));
|
||||
let reason = scanner_post_lease_activity_defer_reason(Some(&before), Ok(after));
|
||||
let reason = scanner_post_lease_activity_defer_reason(Some(&before), Ok(after), None);
|
||||
assert_eq!(reason, Some(ScannerCycleDeferReason::ActivityBaselineUnavailable));
|
||||
|
||||
// A guardrail field moving in the window still defers.
|
||||
let mut moved = before.clone();
|
||||
moved.get_mut("node-2").expect("peer should be present").movement_generation += 1;
|
||||
assert_eq!(
|
||||
scanner_post_lease_activity_defer_reason(Some(&before), Ok(moved)),
|
||||
scanner_post_lease_activity_defer_reason(Some(&before), Ok(moved), None),
|
||||
Some(ScannerCycleDeferReason::ActivityBaselineUnavailable)
|
||||
);
|
||||
|
||||
@@ -10489,7 +10489,7 @@ fn post_lease_activity_proof_requires_a_complete_matching_baseline() {
|
||||
(Some(&blocked), Ok(blocked.clone())),
|
||||
] {
|
||||
assert_eq!(
|
||||
scanner_post_lease_activity_defer_reason(expected, observed),
|
||||
scanner_post_lease_activity_defer_reason(expected, observed, None),
|
||||
Some(ScannerCycleDeferReason::ActivityBaselineUnavailable)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ async fn complete_candidate(store: &Arc<ECStore>, cycle: u64) -> (crate::scanner
|
||||
service_cohort: None,
|
||||
resolved_scope_observer: None,
|
||||
post_walk_gate: None,
|
||||
peer_observations: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -285,6 +286,7 @@ async fn scoped_ack_publication_observed_candidate_reuse_requires_a_new_root_pro
|
||||
service_cohort: None,
|
||||
resolved_scope_observer: Some(observer),
|
||||
post_walk_gate: None,
|
||||
peer_observations: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -456,6 +458,7 @@ async fn scoped_ack_publication_rejects_builder_mutation_after_real_root_publish
|
||||
"remote_scoped_ack_target",
|
||||
"publication_epoch",
|
||||
"remote_lease_targets",
|
||||
"remote_pending",
|
||||
] {
|
||||
let (_directory, store) = candidate_store().await;
|
||||
let (scan, candidate) = complete_candidate(&store, PROOF_CYCLE).await;
|
||||
@@ -502,6 +505,7 @@ async fn scoped_ack_publication_rejects_builder_mutation_after_real_root_publish
|
||||
},
|
||||
}]),
|
||||
"publication_epoch" => scan.with_publication_epoch(Some(changed_epoch)),
|
||||
"remote_pending" => scan.with_remote_dirty_usage_pending(true),
|
||||
"remote_lease_targets" => scan.with_remote_publication_lease_targets(vec![(
|
||||
"proof-peer:9000".to_string(),
|
||||
crate::scanner_activity_epoch().to_string(),
|
||||
@@ -524,3 +528,142 @@ async fn scoped_ack_publication_rejects_builder_mutation_after_real_root_publish
|
||||
}
|
||||
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn certified_empty_remote_ack_keeps_known_remote_work_pending() {
|
||||
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||
let scan = ScannerCycleResult::new(ScannerCycleStatus::Complete, None).with_remote_dirty_usage_pending(true);
|
||||
let (outcome, pending, acknowledgements) = finalize_scanner_cycle_result(scan, DataUsagePersistOutcome::Current.into());
|
||||
assert_eq!(outcome, ScannerCycleOutcome::Completed);
|
||||
assert!(acknowledgements.is_empty());
|
||||
assert!(pending, "an empty ACK cannot hide the remote buckets excluded from certification");
|
||||
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sealed_peer_proof_binds_every_lease_and_keeps_all_post_lease_guardrails() {
|
||||
use crate::storage_api::scan::{
|
||||
EcstoreScannerPeerDirtyUsageBucket, EcstoreScannerPeerDirtyUsageSnapshot, EcstoreScannerPublicationLease,
|
||||
EcstoreScannerPublicationLeaseGrant,
|
||||
};
|
||||
let peer_rows = [("peer-a", 101_u128, 201_u128, true), ("peer-b", 102, 202, false)];
|
||||
let mut before = BTreeMap::new();
|
||||
let mut peers = Vec::new();
|
||||
let mut grants = Vec::new();
|
||||
for (host, owner, instance, pending) in peer_rows {
|
||||
let owner_id = uuid::Uuid::from_u128(owner).simple().to_string();
|
||||
let instance_id = uuid::Uuid::from_u128(instance).simple().to_string();
|
||||
before.insert(host.to_string(), scanner_node_activity_for_tests(&instance_id, 10, 10, pending));
|
||||
let buckets = if pending {
|
||||
BTreeMap::from([(
|
||||
"cold-bucket".to_string(),
|
||||
EcstoreScannerPeerDirtyUsageBucket {
|
||||
bucket_incarnation: uuid::Uuid::from_u128(301),
|
||||
generation: 8,
|
||||
},
|
||||
)])
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
};
|
||||
peers.push((
|
||||
host.to_string(),
|
||||
EcstoreScannerPeerDirtyUsageSnapshot {
|
||||
owner_id: owner_id.clone(),
|
||||
instance_id: instance_id.clone(),
|
||||
generation: 10,
|
||||
pending_bucket_count: buckets.len() as u64,
|
||||
protocol_version: crate::SCANNER_DIRTY_USAGE_SNAPSHOT_PROTOCOL_VERSION,
|
||||
complete: true,
|
||||
buckets,
|
||||
},
|
||||
));
|
||||
grants.push(EcstoreScannerPublicationLeaseGrant {
|
||||
host: host.to_string(),
|
||||
lease: EcstoreScannerPublicationLease {
|
||||
token: uuid::Uuid::new_v4(),
|
||||
movement_generation: 0,
|
||||
observational_only: false,
|
||||
owner_id,
|
||||
session_id: instance_id,
|
||||
expires_at: std::time::Instant::now() + Duration::from_secs(60),
|
||||
},
|
||||
});
|
||||
}
|
||||
let proof = crate::scanner_io::ScannerRemoteDirtyUsageCertification::for_tests(before.clone(), peers)
|
||||
.expect("complete producer evidence");
|
||||
let mut late_write = before.clone();
|
||||
let writer = late_write.get_mut("peer-a").expect("writer");
|
||||
writer.namespace_generation += 1;
|
||||
writer.dirty_usage_generation += 1;
|
||||
assert_eq!(
|
||||
scanner_post_lease_activity_defer_reason(Some(&before), Ok(late_write.clone()), Some((&proof, &grants))),
|
||||
None
|
||||
);
|
||||
|
||||
for case in [
|
||||
"missing",
|
||||
"duplicate",
|
||||
"owner",
|
||||
"instance",
|
||||
"movement",
|
||||
"expired",
|
||||
"observational",
|
||||
"token",
|
||||
] {
|
||||
let mut invalid = grants.clone();
|
||||
match case {
|
||||
"missing" => {
|
||||
invalid.pop();
|
||||
}
|
||||
"duplicate" => invalid[1] = invalid[0].clone(),
|
||||
"owner" => invalid[1].lease.owner_id = uuid::Uuid::from_u128(999).to_string(),
|
||||
"instance" => invalid[1].lease.session_id = uuid::Uuid::from_u128(999).to_string(),
|
||||
"movement" => invalid[1].lease.movement_generation += 1,
|
||||
"expired" => invalid[1].lease.expires_at = std::time::Instant::now() - Duration::from_secs(1),
|
||||
"observational" => invalid[1].lease.observational_only = true,
|
||||
"token" => invalid[1].lease.token = uuid::Uuid::nil(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert_eq!(
|
||||
scanner_post_lease_activity_defer_reason(Some(&before), Ok(before.clone()), Some((&proof, &invalid))),
|
||||
Some(ScannerCycleDeferReason::ActivityBaselineUnavailable),
|
||||
"{case}"
|
||||
);
|
||||
}
|
||||
for field in [
|
||||
"instance",
|
||||
"protocol",
|
||||
"topology",
|
||||
"maintenance",
|
||||
"data_movement",
|
||||
"movement",
|
||||
"blocked",
|
||||
"missing_peer",
|
||||
"overflow",
|
||||
"rollback",
|
||||
] {
|
||||
let mut changed = late_write.clone();
|
||||
let peer = changed.get_mut("peer-a").expect("peer");
|
||||
match field {
|
||||
"instance" => peer.instance_id = uuid::Uuid::from_u128(999).to_string(),
|
||||
"protocol" => peer.protocol_version += 1,
|
||||
"topology" => peer.topology_digest[0] ^= 1,
|
||||
"maintenance" => peer.maintenance_generation += 1,
|
||||
"data_movement" => peer.data_movement_active = true,
|
||||
"movement" => peer.movement_generation += 1,
|
||||
"blocked" => peer.publication_blocked = true,
|
||||
"missing_peer" => {
|
||||
changed.remove("peer-b");
|
||||
}
|
||||
"overflow" => peer.dirty_usage_generation = u64::MAX,
|
||||
"rollback" => peer.dirty_usage_generation = 9,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert_eq!(
|
||||
scanner_post_lease_activity_defer_reason(Some(&before), Ok(changed), Some((&proof, &grants))),
|
||||
Some(ScannerCycleDeferReason::ActivityBaselineUnavailable),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,27 @@ impl DataUsagePublicationResult {
|
||||
(self.outcome, self.proof)
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
pub(super) fn e2e_durable_observation(
|
||||
&self,
|
||||
epoch: u64,
|
||||
cycle: u64,
|
||||
expected: &crate::scanner_io::ScannerPublicationExpectation,
|
||||
) -> Option<serde_json::Value> {
|
||||
let outcome = match self.outcome {
|
||||
DataUsagePersistOutcome::Saved => "saved",
|
||||
DataUsagePersistOutcome::AlreadyDurable => "already_durable",
|
||||
_ => return None,
|
||||
};
|
||||
let (root_version, root_digest) = self.proof.as_ref()?.verified_version_for(expected)?;
|
||||
let (candidate_digest, coverage_digest) = expected.e2e_digests();
|
||||
Some(serde_json::json!({
|
||||
"epoch": epoch, "cycle": cycle, "outcome": outcome,
|
||||
"candidate_sha256": candidate_digest, "coverage_sha256": coverage_digest,
|
||||
"root_version": root_version, "root_sha256": crate::e2e_barrier::hex(root_digest),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn observation_for_attempt(
|
||||
&self,
|
||||
scanner_epoch: u64,
|
||||
@@ -1067,6 +1088,23 @@ where
|
||||
break DataUsagePersistOutcome::Deferred(ScannerCycleDeferReason::PublicationLeaseDeadlineExceeded);
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
if !observational
|
||||
&& target_path == DATA_USAGE_OBJ_NAME_PATH.as_str()
|
||||
&& let (Some(expected), Some(epoch), Some(cycle)) =
|
||||
(ack_expectation.as_ref(), data_usage_info.scanner_epoch, data_usage_info.scanner_cycle)
|
||||
&& expected.matches_encoded_candidate(&data_digest)
|
||||
{
|
||||
let (candidate_digest, coverage_digest) = expected.e2e_digests();
|
||||
let _ = crate::e2e_barrier::checkpoint(
|
||||
"root_before_publish",
|
||||
serde_json::json!({
|
||||
"epoch": epoch, "cycle": cycle,
|
||||
"candidate_sha256": candidate_digest, "coverage_sha256": coverage_digest,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let done_save = Metrics::time(Metric::SaveUsage);
|
||||
let (save_result, commit_state) = {
|
||||
let publication_scope = storeapi
|
||||
|
||||
@@ -938,6 +938,7 @@ pub struct ScannerBucketScanPlan {
|
||||
publication_epoch: Option<u64>,
|
||||
dirty_usage_buckets: Arc<DirtyUsageBuckets>,
|
||||
bucket_failures: ScannerBucketFailureState,
|
||||
bucket_completions: Option<Arc<Mutex<ScannerBucketCompletions>>>,
|
||||
pending_maintenance_work: Arc<AtomicBool>,
|
||||
cache_cycle_floor: Arc<AtomicU64>,
|
||||
cold_zero_walk_reuse_observed: Arc<AtomicBool>,
|
||||
@@ -1272,20 +1273,14 @@ enum ScannerCycleActivityStatus {
|
||||
Unverified,
|
||||
}
|
||||
|
||||
async fn scanner_cycle_activity_status<S>(
|
||||
store: &S,
|
||||
distributed: bool,
|
||||
fn scanner_cycle_activity_status(
|
||||
before: &crate::scanner::ScannerActivitySnapshot,
|
||||
) -> (ScannerCycleActivityStatus, Vec<(String, String, u64)>)
|
||||
where
|
||||
S: ScannerStorage,
|
||||
{
|
||||
// Read the storage movement barrier before sampling the activity
|
||||
// generations. A movement that starts during this await must invalidate
|
||||
// the earlier baseline. Ordinary PUT tails are not part of the barrier:
|
||||
// they shade their bucket and are certified per bucket below.
|
||||
let publication_blocked = store.scanner_data_usage_publication_blocked().await;
|
||||
match crate::scanner::probe_scanner_activity(store, distributed).await {
|
||||
observed: std::result::Result<crate::scanner::ScannerActivitySnapshot, String>,
|
||||
publication_blocked: bool,
|
||||
certification: Option<&ScannerRemoteDirtyUsageCertification>,
|
||||
certification_required: bool,
|
||||
) -> (ScannerCycleActivityStatus, Vec<(String, String, u64)>) {
|
||||
match observed {
|
||||
Ok(after) => {
|
||||
let veto = if publication_blocked {
|
||||
Some(crate::scanner::ScannerActivityGuardrailVeto::new(
|
||||
@@ -1309,10 +1304,15 @@ where
|
||||
);
|
||||
global_metrics().record_scanner_guardrail_veto(veto.reason());
|
||||
ScannerCycleActivityStatus::Changed
|
||||
} else if let Some(moved) = crate::scanner::scanner_peer_dirty_usage_moved(before, &after) {
|
||||
// Per-bucket certification covers the local dirty map only;
|
||||
// a peer whose dirty state moved keeps today's whole-cycle
|
||||
// outcome until scoped acknowledgements can name its buckets.
|
||||
} else if certification_required && certification.is_none()
|
||||
|| certification.is_some_and(|proof| !proof.matches_activity(&after))
|
||||
{
|
||||
ScannerCycleActivityStatus::Unverified
|
||||
} else if let Some(moved) =
|
||||
crate::scanner::scanner_peer_dirty_usage_moved(before, &after).filter(|_| certification.is_none())
|
||||
{
|
||||
// Without a complete producer proof, retain the original
|
||||
// whole-cycle fallback for a moving peer.
|
||||
tracing::info!(
|
||||
target: "rustfs::scanner::io",
|
||||
event = EVENT_SCANNER_SET_STATE,
|
||||
@@ -1743,6 +1743,8 @@ pub(crate) struct ScannerCycleResult {
|
||||
partial_bucket_progress: bool,
|
||||
dirty_usage_clear: Option<DirtyUsageBuckets>,
|
||||
remote_dirty_usage_acknowledgements: Vec<crate::scanner::ScannerDirtyUsageAcknowledgement>,
|
||||
remote_dirty_usage_certification: Option<ScannerRemoteDirtyUsageCertification>,
|
||||
remote_dirty_usage_pending: bool,
|
||||
distributed_segment_invalidation_evidence: Option<DistributedSegmentInvalidationEvidence>,
|
||||
segment_reuse_activation_preflight: ScannerSegmentReuseActivationPreflight,
|
||||
remote_publication_lease_targets: Vec<(String, String, u64)>,
|
||||
@@ -1765,6 +1767,8 @@ impl ScannerCycleResult {
|
||||
partial_bucket_progress: false,
|
||||
dirty_usage_clear,
|
||||
remote_dirty_usage_acknowledgements: Vec::new(),
|
||||
remote_dirty_usage_certification: None,
|
||||
remote_dirty_usage_pending: false,
|
||||
distributed_segment_invalidation_evidence: None,
|
||||
segment_reuse_activation_preflight: scanner_segment_reuse_activation_preflight(),
|
||||
remote_publication_lease_targets: Vec::new(),
|
||||
@@ -1888,6 +1892,26 @@ impl ScannerCycleResult {
|
||||
self
|
||||
}
|
||||
|
||||
fn with_remote_dirty_usage_certification(mut self, certification: Option<ScannerRemoteDirtyUsageCertification>) -> Self {
|
||||
self.publication_expectation = None;
|
||||
self.remote_dirty_usage_certification = certification;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn remote_dirty_usage_certification(&self) -> Option<&ScannerRemoteDirtyUsageCertification> {
|
||||
self.remote_dirty_usage_certification.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn with_remote_dirty_usage_pending(mut self, pending: bool) -> Self {
|
||||
self.publication_expectation = None;
|
||||
self.remote_dirty_usage_pending = pending;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn has_remote_dirty_usage_pending(&self) -> bool {
|
||||
self.remote_dirty_usage_pending
|
||||
}
|
||||
|
||||
fn with_distributed_segment_invalidation_evidence(
|
||||
mut self,
|
||||
evidence: Option<DistributedSegmentInvalidationEvidence>,
|
||||
@@ -1969,9 +1993,12 @@ mod guards;
|
||||
pub(crate) use guards::ScannerServiceCohort;
|
||||
mod io_cache;
|
||||
mod io_cycle;
|
||||
mod peer_certification;
|
||||
#[cfg(test)]
|
||||
use io_cache::{ScannerSetCacheGeneration, prepare_scoped_set_scan};
|
||||
pub(crate) use io_cycle::{ScannerCycleRequest, nsscanner_with_storage_status_scoped};
|
||||
pub(crate) use peer_certification::ScannerRemoteDirtyUsageCertification;
|
||||
use peer_certification::{ScannerBucketCompletions, ScannerRemoteDirtyUsageWindow};
|
||||
mod io_disk;
|
||||
#[cfg(test)]
|
||||
mod publish_gate_tests;
|
||||
|
||||
@@ -589,6 +589,11 @@ impl ScannerPublicationExpectation {
|
||||
Arc::ptr_eq(&self.candidate, &other.candidate) && self.candidate.1 == other.candidate.1
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
pub(crate) fn e2e_digests(&self) -> (String, String) {
|
||||
(crate::e2e_barrier::hex(&self.candidate.0), crate::e2e_barrier::hex(&self.candidate.1.0))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn for_tests(candidate_digest: [u8; 32], coverage_digest: DataUsageScanPlanDigest) -> Self {
|
||||
Self {
|
||||
|
||||
@@ -282,6 +282,7 @@ impl ScannerIOCache for SetDisks {
|
||||
publication_epoch,
|
||||
dirty_usage_buckets,
|
||||
bucket_failures,
|
||||
bucket_completions,
|
||||
pending_maintenance_work,
|
||||
cache_cycle_floor,
|
||||
cold_zero_walk_reuse_observed,
|
||||
@@ -783,6 +784,8 @@ impl ScannerIOCache for SetDisks {
|
||||
let scope_clone = scope.clone();
|
||||
let cache_cycle_floor_clone = cache_cycle_floor.clone();
|
||||
let expected_publication_epoch_clone = expected_publication_epoch;
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
let worker_incarnations = current_bucket_incarnations.clone();
|
||||
let remote_server_epoch = match worker_mode {
|
||||
NamespaceScannerWorkerMode::RemoteV4(server_epoch) => Some(server_epoch),
|
||||
NamespaceScannerWorkerMode::Coordinator => None,
|
||||
@@ -883,7 +886,7 @@ impl ScannerIOCache for SetDisks {
|
||||
break;
|
||||
};
|
||||
remote_session_sequence = next_sequence;
|
||||
let remote_outcome = crate::remote_scanner::scan_remote_bucket(
|
||||
let remote_scan = crate::remote_scanner::scan_remote_bucket(
|
||||
&disk,
|
||||
ctx_clone.clone(),
|
||||
budget_clone.clone(),
|
||||
@@ -899,8 +902,13 @@ impl ScannerIOCache for SetDisks {
|
||||
skip_healing: healing,
|
||||
scan_mode,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
);
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
let remote_scan = crate::e2e_barrier::worker_observation::with_incarnation(
|
||||
worker_incarnations.as_ref().and_then(|values| values.get(&bucket.name)).copied(),
|
||||
remote_scan,
|
||||
);
|
||||
let remote_outcome = remote_scan.await;
|
||||
match remote_outcome {
|
||||
Ok(crate::remote_scanner::RemoteScannerOutcome::Complete {
|
||||
usage,
|
||||
@@ -1296,6 +1304,23 @@ impl ScannerIOCache for SetDisks {
|
||||
let (checkpoint_tx, mut checkpoint_rx) = mpsc::channel::<DataUsageCache>(1);
|
||||
|
||||
let scan_ctx = ctx_clone.child_token();
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
{
|
||||
let disk_uuid = if crate::e2e_barrier::worker_observation::enabled() {
|
||||
crate::storage_api::owner::EcstoreDiskAPI::get_disk_id(disk.as_ref()).await.ok().flatten()
|
||||
} else { None };
|
||||
let location = disk.get_disk_location();
|
||||
let _ = crate::e2e_barrier::checkpoint("bucket_walk", serde_json::json!({
|
||||
"epoch": leader_epoch, "cycle": want_cycle,
|
||||
"pool": source.pool_index, "set": source.set_index,
|
||||
"bucket": bucket.name, "drive": disk.path().display().to_string(),
|
||||
"worker_kind": "local", "worker_path": disk.path().display().to_string(),
|
||||
"worker_endpoint": crate::storage_api::owner::EcstoreDiskAPI::endpoint(disk.as_ref()).grid_host(),
|
||||
"disk_uuid": disk_uuid,
|
||||
"disk_index": location.disk_idx,
|
||||
"bucket_incarnation": worker_incarnations.as_ref().and_then(|values| values.get(&bucket.name)),
|
||||
})).await;
|
||||
}
|
||||
let scan = disk.clone().nsscanner_disk(
|
||||
scan_ctx.clone(),
|
||||
budget_clone.clone(),
|
||||
@@ -1707,6 +1732,22 @@ impl ScannerIOCache for SetDisks {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// Record only actual worker completions, before adding carried values.
|
||||
// The root's cold entries cannot certify a newly observed peer tuple.
|
||||
if let Some(completions) = bucket_completions {
|
||||
let completed_incarnations = completed_buckets
|
||||
.iter()
|
||||
.filter_map(|bucket| {
|
||||
current_bucket_incarnations
|
||||
.as_ref()?
|
||||
.get(bucket)
|
||||
.filter(|incarnation| !incarnation.is_nil())
|
||||
.map(|incarnation| (bucket.clone(), *incarnation))
|
||||
})
|
||||
.collect();
|
||||
completions.lock().await.insert(source, completed_incarnations);
|
||||
}
|
||||
|
||||
let carried_buckets = carry_partial_bucket_values(
|
||||
&mut cache,
|
||||
&old_cache,
|
||||
|
||||
@@ -79,6 +79,8 @@ where
|
||||
resolved_scope_observer: None,
|
||||
#[cfg(test)]
|
||||
post_walk_gate: None,
|
||||
#[cfg(test)]
|
||||
peer_observations: None,
|
||||
};
|
||||
nsscanner_with_storage_status_scoped(store, request).await
|
||||
}
|
||||
@@ -103,10 +105,133 @@ pub(crate) struct ScannerCycleRequest {
|
||||
/// certification regardless of how fast the walk finishes.
|
||||
#[cfg(test)]
|
||||
pub(crate) post_walk_gate: Option<tokio::sync::oneshot::Receiver<()>>,
|
||||
#[cfg(test)]
|
||||
pub(crate) peer_observations: Option<ScannerCyclePeerObservations>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ScannerCyclePeerObservations {
|
||||
pub(crate) activity: Arc<dyn Fn() -> crate::scanner::ScannerActivitySnapshot + Send + Sync>,
|
||||
pub(crate) dirty_usage: Arc<dyn Fn() -> Vec<(String, EcstoreScannerPeerDirtyUsageSnapshot)> + Send + Sync>,
|
||||
pub(crate) scoped_ack_capable: bool,
|
||||
}
|
||||
|
||||
pub(super) async fn probe_scanner_cycle_activity<S>(
|
||||
store: &S,
|
||||
distributed: bool,
|
||||
#[cfg(test)] peer_observations: Option<&ScannerCyclePeerObservations>,
|
||||
) -> std::result::Result<crate::scanner::ScannerActivitySnapshot, String>
|
||||
where
|
||||
S: ScannerStorage,
|
||||
{
|
||||
#[cfg(test)]
|
||||
if let Some(peers) = peer_observations {
|
||||
let mut snapshot = crate::scanner::probe_scanner_activity(store, false).await?;
|
||||
for (host, activity) in (peers.activity)() {
|
||||
if snapshot.insert(host, activity).is_some() {
|
||||
return Err("duplicate test peer activity".to_string());
|
||||
}
|
||||
}
|
||||
return Ok(snapshot);
|
||||
}
|
||||
crate::scanner::probe_scanner_activity(store, distributed).await
|
||||
}
|
||||
|
||||
async fn observe_remote_dirty_usage_window<S>(
|
||||
store: &S,
|
||||
before: &crate::scanner::ScannerActivitySnapshot,
|
||||
_stage: &str,
|
||||
_epoch: u64,
|
||||
_cycle: u64,
|
||||
#[cfg(test)] peer_observations: Option<&ScannerCyclePeerObservations>,
|
||||
) -> (
|
||||
Option<ScannerRemoteDirtyUsageWindow>,
|
||||
std::result::Result<crate::scanner::ScannerActivitySnapshot, String>,
|
||||
)
|
||||
where
|
||||
S: ScannerStorage,
|
||||
{
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
let _ =
|
||||
crate::e2e_barrier::checkpoint(&format!("before_{_stage}"), serde_json::json!({"epoch": _epoch, "cycle": _cycle})).await;
|
||||
#[cfg(test)]
|
||||
let snapshots = if let Some(peers) = peer_observations {
|
||||
Ok((peers.dirty_usage)())
|
||||
} else if let Some(notification) = store.scanner_notification_system() {
|
||||
notification
|
||||
.scanner_dirty_usage_snapshots()
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
} else {
|
||||
Err("scanner peer snapshot service is unavailable".to_string())
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
let snapshots = if let Some(notification) = store.scanner_notification_system() {
|
||||
notification
|
||||
.scanner_dirty_usage_snapshots()
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
} else {
|
||||
Err("scanner peer snapshot service is unavailable".to_string())
|
||||
};
|
||||
let after = probe_scanner_cycle_activity(
|
||||
store,
|
||||
true,
|
||||
#[cfg(test)]
|
||||
peer_observations,
|
||||
)
|
||||
.await;
|
||||
let window = match (snapshots, &after) {
|
||||
(Ok(snapshots), Ok(after)) => ScannerRemoteDirtyUsageWindow::verify(before.clone(), snapshots, after.clone()),
|
||||
_ => None,
|
||||
};
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
let _ = crate::e2e_barrier::checkpoint(
|
||||
_stage,
|
||||
window.as_ref().map_or_else(
|
||||
|| serde_json::json!({"epoch": _epoch, "cycle": _cycle, "verified": false}),
|
||||
|window| window.observation(_epoch, _cycle),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
(window, after)
|
||||
}
|
||||
|
||||
async fn remote_dirty_usage_window_supports_scoped_ack<S>(
|
||||
store: &S,
|
||||
window: &ScannerRemoteDirtyUsageWindow,
|
||||
#[cfg(test)] peer_observations: Option<&ScannerCyclePeerObservations>,
|
||||
) -> bool
|
||||
where
|
||||
S: ScannerStorage,
|
||||
{
|
||||
let acknowledgements = window.acknowledgements();
|
||||
if scanner_scoped_dirty_usage_ack_exceeds_cost_threshold(&acknowledgements) {
|
||||
return false;
|
||||
}
|
||||
// Empty peers remain in the proof; they need no empty capability request.
|
||||
if acknowledgements.is_empty() {
|
||||
return true;
|
||||
}
|
||||
#[cfg(test)]
|
||||
if let Some(peers) = peer_observations {
|
||||
return peers.scoped_ack_capable;
|
||||
}
|
||||
let Some(notification) = store.scanner_notification_system() else {
|
||||
return false;
|
||||
};
|
||||
matches!(
|
||||
notification
|
||||
.scanner_scoped_dirty_usage_capabilities(acknowledgements.into_iter().map(Into::into).collect(),)
|
||||
.await,
|
||||
Ok(true)
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) struct ScannerBucketScopeResolution<'a> {
|
||||
pub(super) requested_scope: ScannerBucketScanScope,
|
||||
pub(super) remote_window: Option<&'a ScannerRemoteDirtyUsageWindow>,
|
||||
pub(super) baseline_proof: ScannerCacheBaselineProof<'a>,
|
||||
pub(super) activity_before: &'a crate::scanner::ScannerActivitySnapshot,
|
||||
pub(super) dirty_usage_snapshot: &'a DirtyUsageSnapshot,
|
||||
@@ -149,6 +274,29 @@ where
|
||||
.cloned()
|
||||
.collect::<HashSet<_>>();
|
||||
if distributed {
|
||||
if let Some(window) = resolution.remote_window {
|
||||
if let Some(strict_remote_usage) = window.strict_remote_usage() {
|
||||
// Only the original quiet window can supply segment reuse evidence.
|
||||
return resolve_remote_dirty_usage_scope(
|
||||
resolution.requested_scope,
|
||||
resolution.dirty_usage_snapshot,
|
||||
strict_remote_usage,
|
||||
resolution.all_buckets,
|
||||
resolution.baseline_proof,
|
||||
);
|
||||
}
|
||||
let mut dirty_buckets = dirty_buckets;
|
||||
dirty_buckets.extend(window.dirty_buckets());
|
||||
return default_result(scoped_scan_scope_from_dirty_buckets(
|
||||
resolution.requested_scope,
|
||||
dirty_buckets,
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
resolution.all_buckets,
|
||||
resolution.baseline_proof,
|
||||
));
|
||||
}
|
||||
let notification_system = store.scanner_notification_system();
|
||||
#[cfg(test)]
|
||||
let peer_snapshots = if let Some(peer_snapshots) = resolution.test_peer_snapshots.clone() {
|
||||
@@ -322,7 +470,11 @@ where
|
||||
resolved_scope_observer,
|
||||
#[cfg(test)]
|
||||
post_walk_gate,
|
||||
#[cfg(test)]
|
||||
peer_observations,
|
||||
} = request;
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
let requested_scope = e2e_scope_observation(&scan_scope);
|
||||
let child_token = ctx.child_token();
|
||||
let _tier_cycle_guard = begin_tier_registry_cycle(want_cycle, leader_epoch);
|
||||
|
||||
@@ -359,7 +511,17 @@ where
|
||||
};
|
||||
|
||||
let distributed = store.setup_is_dist_erasure().await;
|
||||
let activity_before = match scanner_activity_preflight(crate::scanner::probe_scanner_activity(store, distributed).await) {
|
||||
#[cfg(test)]
|
||||
let distributed = distributed || peer_observations.is_some();
|
||||
let activity_before = match scanner_activity_preflight(
|
||||
probe_scanner_cycle_activity(
|
||||
store,
|
||||
distributed,
|
||||
#[cfg(test)]
|
||||
peer_observations.as_ref(),
|
||||
)
|
||||
.await,
|
||||
) {
|
||||
ScannerActivityPreflight::Ready(snapshot) => Arc::new(snapshot),
|
||||
ScannerActivityPreflight::ActivityBaselineUnavailable(err) => {
|
||||
warn!(
|
||||
@@ -420,6 +582,39 @@ where
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.refresh(&buckets_by_source);
|
||||
}
|
||||
let full_scan_required = requires_full_scan || scan_mode == HealScanMode::Deep;
|
||||
let scope_eligible = !full_scan_required && scan_scope.is_default();
|
||||
let mut remote_start = if distributed && scope_eligible && !all_buckets.is_empty() {
|
||||
observe_remote_dirty_usage_window(
|
||||
store,
|
||||
&activity_before,
|
||||
"start_snapshot",
|
||||
leader_epoch,
|
||||
want_cycle,
|
||||
#[cfg(test)]
|
||||
peer_observations.as_ref(),
|
||||
)
|
||||
.await
|
||||
.0
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(window) = remote_start.as_ref()
|
||||
&& !remote_dirty_usage_window_supports_scoped_ack(
|
||||
store,
|
||||
window,
|
||||
#[cfg(test)]
|
||||
peer_observations.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
remote_start = None;
|
||||
}
|
||||
// Work and Current-cache identities include writes admitted by the start
|
||||
// window. Its earlier edge remains in the producer proof, not the cache key.
|
||||
let activity_before = remote_start
|
||||
.as_ref()
|
||||
.map_or(activity_before, ScannerRemoteDirtyUsageWindow::activity_for_walk);
|
||||
let structural_scan_plan_digest =
|
||||
scanner_bucket_plan_digest(&all_buckets, crate::scanner::scanner_activity_structural_digest(&activity_before));
|
||||
let scan_plan_digest = scanner_bucket_work_digest(structural_scan_plan_digest, scan_mode, requires_full_scan);
|
||||
@@ -437,22 +632,21 @@ where
|
||||
want_cycle,
|
||||
scan_plan_digest: structural_scan_plan_digest,
|
||||
};
|
||||
let full_scan_required = requires_full_scan || scan_mode == HealScanMode::Deep;
|
||||
let scope_eligible = !full_scan_required && scan_scope.is_default();
|
||||
let scope_resolution = resolve_scanner_bucket_scan_scope(
|
||||
store,
|
||||
distributed,
|
||||
ScannerBucketScopeResolution {
|
||||
requested_scope: scan_scope,
|
||||
remote_window: remote_start.as_ref(),
|
||||
baseline_proof,
|
||||
activity_before: &activity_before,
|
||||
dirty_usage_snapshot: &dirty_usage_snapshot,
|
||||
all_buckets: &all_buckets,
|
||||
requires_full_scan: full_scan_required,
|
||||
#[cfg(test)]
|
||||
test_peer_snapshots: None,
|
||||
test_peer_snapshots: peer_observations.as_ref().map(|peers| (peers.dirty_usage)()),
|
||||
#[cfg(test)]
|
||||
test_scoped_dirty_usage_capability: None,
|
||||
test_scoped_dirty_usage_capability: peer_observations.as_ref().map(|peers| peers.scoped_ack_capable),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -496,8 +690,16 @@ where
|
||||
if !bucket_plan_complete {
|
||||
return Ok(ScannerCycleResult::new(ScannerCycleStatus::Incomplete, None).with_publication_epoch(publication_epoch));
|
||||
}
|
||||
let publication_blocked = store.scanner_data_usage_publication_blocked().await;
|
||||
let activity_after = probe_scanner_cycle_activity(
|
||||
store,
|
||||
distributed,
|
||||
#[cfg(test)]
|
||||
peer_observations.as_ref(),
|
||||
)
|
||||
.await;
|
||||
let (activity_status, remote_publication_lease_targets) =
|
||||
scanner_cycle_activity_status(store, distributed, &activity_before).await;
|
||||
scanner_cycle_activity_status(&activity_before, activity_after, publication_blocked, None, false);
|
||||
let dirty_usage_status = dirty_usage_snapshot_status(&dirty_usage_snapshot);
|
||||
let status = classify_nsscanner_cycle(
|
||||
true,
|
||||
@@ -583,6 +785,15 @@ where
|
||||
let serial_progress_accounting = budget.requires_serial_progress_accounting();
|
||||
let set_scan_limit = scanner_budgeted_concurrency_limit(dispatch_limit, serial_progress_accounting);
|
||||
let bucket_failures = ScannerBucketFailureState::default();
|
||||
let bucket_completions = remote_start
|
||||
.as_ref()
|
||||
.map(|_| Arc::new(Mutex::new(ScannerBucketCompletions::new())));
|
||||
// Recovery sweeps have no remote certification window, but test sessions
|
||||
// still need the actual worker completions before any values are carried.
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
let bucket_completions = bucket_completions.or_else(|| {
|
||||
std::env::var_os("RUSTFS_E2E_SCANNER_BARRIER_DIR").map(|_| Arc::new(Mutex::new(ScannerBucketCompletions::new())))
|
||||
});
|
||||
let pending_maintenance_work = Arc::new(AtomicBool::new(false));
|
||||
let cold_zero_walk_reuse_observed = Arc::new(AtomicBool::new(false));
|
||||
record_set_scan_concurrency_limit(set_scan_limit);
|
||||
@@ -679,6 +890,7 @@ where
|
||||
publication_epoch,
|
||||
dirty_usage_buckets: dirty_usage_snapshot.buckets.clone(),
|
||||
bucket_failures: bucket_failures.clone(),
|
||||
bucket_completions: bucket_completions.clone(),
|
||||
pending_maintenance_work: pending_maintenance_work.clone(),
|
||||
cache_cycle_floor: cache_cycle_floor.clone(),
|
||||
cold_zero_walk_reuse_observed: cold_zero_walk_reuse_observed.clone(),
|
||||
@@ -827,8 +1039,43 @@ where
|
||||
!namespace_not_found_buckets.is_empty(),
|
||||
);
|
||||
let dirty_usage_status = dirty_usage_snapshot_status(&dirty_usage_snapshot);
|
||||
let (activity_status, remote_publication_lease_targets) =
|
||||
scanner_cycle_activity_status(store, distributed, &activity_before).await;
|
||||
// Check the movement barrier before sampling either edge. The two RPC
|
||||
// windows are single observations; publication never waits for silence.
|
||||
let publication_blocked = store.scanner_data_usage_publication_blocked().await;
|
||||
let post_activity_before = probe_scanner_cycle_activity(
|
||||
store,
|
||||
distributed,
|
||||
#[cfg(test)]
|
||||
peer_observations.as_ref(),
|
||||
)
|
||||
.await;
|
||||
let remote_certification_attempted = remote_start.is_some();
|
||||
let (remote_certification, post_activity) = match (remote_start, post_activity_before) {
|
||||
(Some(start), Ok(before)) => {
|
||||
let (post_walk, after) = observe_remote_dirty_usage_window(
|
||||
store,
|
||||
&before,
|
||||
"post_walk_snapshot",
|
||||
leader_epoch,
|
||||
want_cycle,
|
||||
#[cfg(test)]
|
||||
peer_observations.as_ref(),
|
||||
)
|
||||
.await;
|
||||
(
|
||||
post_walk.and_then(|post_walk| ScannerRemoteDirtyUsageCertification::new(start, post_walk)),
|
||||
after,
|
||||
)
|
||||
}
|
||||
(_, after) => (None, after),
|
||||
};
|
||||
let (activity_status, remote_publication_lease_targets) = scanner_cycle_activity_status(
|
||||
&activity_before,
|
||||
post_activity,
|
||||
publication_blocked,
|
||||
remote_certification.as_ref(),
|
||||
remote_certification_attempted,
|
||||
);
|
||||
let all_bucket_names = all_buckets.iter().map(|bucket| bucket.name.clone()).collect::<Vec<_>>();
|
||||
let completed_usage = completed_usage_candidate(
|
||||
&results,
|
||||
@@ -877,17 +1124,93 @@ where
|
||||
// stopped early is pending for the same reason: the publication carries
|
||||
// its last certified value, not this walk's (a `Complete` cycle has only
|
||||
// carried partial buckets, see `classify_nsscanner_cycle`).
|
||||
let mut uncertified_buckets = failed_buckets.union(&partial_buckets).cloned().collect::<HashSet<_>>();
|
||||
let local_pending_buckets = pending_dirty_usage_buckets(&dirty_usage_snapshot.buckets);
|
||||
uncertified_buckets.extend(local_pending_buckets.iter().cloned());
|
||||
let completions = match bucket_completions {
|
||||
Some(completions) => completions.lock().await.clone(),
|
||||
None => ScannerBucketCompletions::new(),
|
||||
};
|
||||
let (certified_acknowledgements, remote_pending_buckets) = remote_certification.as_ref().map_or_else(
|
||||
|| (Vec::new(), ScannerCyclePendingBuckets::new()),
|
||||
|proof| proof.classify_buckets(&expected_sources, &completions, &uncertified_buckets),
|
||||
);
|
||||
let remote_dirty_usage_pending = !remote_pending_buckets.is_empty();
|
||||
uncertified_buckets.extend(remote_pending_buckets.iter().cloned());
|
||||
let pending_buckets = if cycle_status == ScannerCycleStatus::Complete {
|
||||
let mut pending = pending_dirty_usage_buckets(&dirty_usage_snapshot.buckets);
|
||||
let mut pending = local_pending_buckets;
|
||||
pending.extend(partial_buckets.iter().cloned());
|
||||
pending.extend(remote_pending_buckets);
|
||||
pending
|
||||
} else {
|
||||
ScannerCyclePendingBuckets::new()
|
||||
};
|
||||
// A verified but empty scoped ACK is deliberate. It must never become a
|
||||
// whole-generation ACK that could clear a hot or carried partial bucket.
|
||||
let remote_dirty_usage_acknowledgements = if cycle_status != ScannerCycleStatus::Complete {
|
||||
Vec::new()
|
||||
} else if remote_certification.is_some() {
|
||||
certified_acknowledgements
|
||||
} else if remote_certification_attempted {
|
||||
Vec::new()
|
||||
} else if !remote_dirty_usage_acknowledgements.is_empty() {
|
||||
remote_dirty_usage_acknowledgements
|
||||
} else if scan_scope.is_default() {
|
||||
crate::scanner::scanner_dirty_usage_acknowledgements(&activity_before)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut publication_expectation = None;
|
||||
let usage_update_emitted = if let Some(candidate) = completed_usage {
|
||||
let (data_usage_info, expectation) = candidate.prepare(cycle_status, &pending_buckets);
|
||||
publication_expectation = expectation;
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
{
|
||||
let digests = publication_expectation
|
||||
.as_ref()
|
||||
.map(ScannerPublicationExpectation::e2e_digests);
|
||||
let scanned_buckets = completions
|
||||
.values()
|
||||
.flat_map(|buckets| buckets.keys().cloned())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut set_coverage = completions.iter().collect::<Vec<_>>();
|
||||
set_coverage.sort_by_key(|(source, _)| (source.pool_index, source.set_index));
|
||||
let _ = crate::e2e_barrier::checkpoint(
|
||||
"cycle_candidate",
|
||||
serde_json::json!({
|
||||
"epoch": leader_epoch,
|
||||
"cycle": want_cycle,
|
||||
"scan_mode": if scan_mode == HealScanMode::Deep { "deep" } else { "normal" },
|
||||
"requested_scope": requested_scope,
|
||||
"selected_scope": e2e_scope_observation(&scan_scope),
|
||||
"scanned_buckets": scanned_buckets,
|
||||
"pending_buckets": data_usage_info.usage_snapshot_pending_buckets,
|
||||
"converged": data_usage_info.usage_snapshot_converged,
|
||||
"status": match cycle_status {
|
||||
ScannerCycleStatus::Complete => "complete",
|
||||
ScannerCycleStatus::Incomplete => "incomplete",
|
||||
ScannerCycleStatus::Superseded => "superseded",
|
||||
ScannerCycleStatus::Deferred(_) => "deferred",
|
||||
},
|
||||
"candidate_sha256": digests.as_ref().map(|digests| &digests.0),
|
||||
"coverage_sha256": digests.as_ref().map(|digests| &digests.1),
|
||||
"peer_certification": remote_certification.is_some(),
|
||||
"certified_peers": remote_certification.as_ref().map(|proof| {
|
||||
proof.post_walk.peers.iter().map(|(host, peer)| serde_json::json!({
|
||||
"host": host, "owner_id": peer.owner_id, "instance_id": peer.instance_id,
|
||||
})).collect::<Vec<_>>()
|
||||
}).unwrap_or_default(),
|
||||
"set_coverage": set_coverage.into_iter().map(|(source, buckets)| serde_json::json!({
|
||||
"pool": source.pool_index,
|
||||
"set": source.set_index,
|
||||
"buckets": buckets.keys().cloned().collect::<BTreeSet<_>>(),
|
||||
"bucket_incarnations": buckets,
|
||||
})).collect::<Vec<_>>(),
|
||||
"acks": crate::e2e_barrier::acknowledgement_observations(&remote_dirty_usage_acknowledgements),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if should_publish_observational_snapshot(cycle_status) {
|
||||
publish_observational_snapshot(&updates, data_usage_info).await?
|
||||
} else {
|
||||
@@ -902,7 +1225,6 @@ where
|
||||
};
|
||||
// A walk that stopped early certifies nothing: the bucket keeps its
|
||||
// dirty generation like a failed one and is walked again next cycle.
|
||||
let uncertified_buckets = failed_buckets.union(&partial_buckets).cloned().collect::<HashSet<_>>();
|
||||
let dirty_usage_clear = should_clear_dirty_usage_snapshot(
|
||||
result.is_ok(),
|
||||
structurally_complete_snapshot,
|
||||
@@ -915,14 +1237,6 @@ where
|
||||
if cycle_status == ScannerCycleStatus::Complete {
|
||||
complete_tier_registry_cycle(want_cycle, leader_epoch);
|
||||
}
|
||||
let remote_dirty_usage_acknowledgements =
|
||||
if cycle_status == ScannerCycleStatus::Complete && !remote_dirty_usage_acknowledgements.is_empty() {
|
||||
remote_dirty_usage_acknowledgements
|
||||
} else if cycle_status == ScannerCycleStatus::Complete && scan_scope.is_default() {
|
||||
crate::scanner::scanner_dirty_usage_acknowledgements(&activity_before)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok(ScannerCycleResult::new(cycle_status, dirty_usage_clear)
|
||||
.with_publication_epoch(publication_epoch)
|
||||
.with_activity_digest(activity_digest)
|
||||
@@ -932,6 +1246,8 @@ where
|
||||
.with_partial_bucket_progress(partial_bucket_progress)
|
||||
.with_remote_publication_lease_targets(remote_publication_lease_targets)
|
||||
.with_remote_dirty_usage_acknowledgements(remote_dirty_usage_acknowledgements)
|
||||
.with_remote_dirty_usage_certification(remote_certification)
|
||||
.with_remote_dirty_usage_pending(remote_dirty_usage_pending)
|
||||
.with_distributed_segment_invalidation_evidence(distributed_segment_invalidation_evidence)
|
||||
.with_segment_reuse_activation_preflight(segment_reuse_activation_preflight)
|
||||
.with_failed_dirty_usage(!failed_buckets.is_empty())
|
||||
@@ -940,3 +1256,11 @@ where
|
||||
.with_scope_fallback(scope_fallback)
|
||||
.with_publication_expectation(publication_expectation))
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
fn e2e_scope_observation(scope: &ScannerBucketScanScope) -> serde_json::Value {
|
||||
match &scope.selected_buckets {
|
||||
Some(buckets) => serde_json::json!({"kind": "buckets", "buckets": buckets.iter().collect::<BTreeSet<_>>()}),
|
||||
None => serde_json::json!({"kind": "all"}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed 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 super::*;
|
||||
use crate::scanner::{
|
||||
ScannerActivitySnapshot, ScannerDirtyUsageAcknowledgement, ScannerDirtyUsageAcknowledgementKind,
|
||||
scanner_activity_allows_usage_publication, scanner_activity_dirty_usage_state_for_host, scanner_activity_guardrail_veto,
|
||||
scanner_activity_publication_lease_targets,
|
||||
};
|
||||
use crate::storage_api::EcstoreScannerPublicationLeaseGrant;
|
||||
|
||||
/// Only worker results received in this cycle enter this map. A carried value
|
||||
/// or a cold bucket copied from the old set root is not a completion.
|
||||
pub(super) type ScannerBucketCompletions = HashMap<DataUsageCacheSource, HashMap<String, uuid::Uuid>>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct ScannerRemoteDirtyUsageWindow {
|
||||
pub(super) before: ScannerActivitySnapshot,
|
||||
pub(super) after: ScannerActivitySnapshot,
|
||||
pub(super) peers: BTreeMap<String, EcstoreScannerPeerDirtyUsageSnapshot>,
|
||||
}
|
||||
|
||||
fn remote_generations_advance(before: &ScannerActivitySnapshot, after: &ScannerActivitySnapshot) -> bool {
|
||||
scanner_activity_allows_usage_publication(before)
|
||||
&& scanner_activity_allows_usage_publication(after)
|
||||
&& scanner_activity_guardrail_veto(before, after).is_none()
|
||||
&& scanner_activity_publication_lease_targets(before).iter().all(|(host, _, _)| {
|
||||
let Some((_, before_generation, _)) = scanner_activity_dirty_usage_state_for_host(before, host) else {
|
||||
return false;
|
||||
};
|
||||
scanner_activity_dirty_usage_state_for_host(after, host)
|
||||
.is_some_and(|(_, after_generation, _)| before_generation <= after_generation && after_generation < u64::MAX)
|
||||
})
|
||||
}
|
||||
|
||||
impl ScannerRemoteDirtyUsageWindow {
|
||||
/// The caller obtains these in order: before activity, dirty snapshot,
|
||||
/// after activity. The generation can advance without waiting for silence.
|
||||
pub(super) fn verify(
|
||||
before: ScannerActivitySnapshot,
|
||||
snapshots: Vec<(String, EcstoreScannerPeerDirtyUsageSnapshot)>,
|
||||
after: ScannerActivitySnapshot,
|
||||
) -> Option<Self> {
|
||||
let expected = scanner_activity_publication_lease_targets(&before);
|
||||
if expected.is_empty() || expected.len() != snapshots.len() || !remote_generations_advance(&before, &after) {
|
||||
return None;
|
||||
}
|
||||
let mut peers = BTreeMap::new();
|
||||
for (host, snapshot) in snapshots {
|
||||
let (instance_id, lower, lower_pending) = scanner_activity_dirty_usage_state_for_host(&before, &host)?;
|
||||
let (_, upper, upper_pending) = scanner_activity_dirty_usage_state_for_host(&after, &host)?;
|
||||
let pending = !snapshot.buckets.is_empty();
|
||||
if host.is_empty()
|
||||
|| snapshot.instance_id != instance_id
|
||||
|| uuid::Uuid::parse_str(&snapshot.owner_id)
|
||||
.ok()
|
||||
.is_none_or(|owner| owner.is_nil())
|
||||
|| uuid::Uuid::parse_str(&snapshot.instance_id)
|
||||
.ok()
|
||||
.is_none_or(|instance| instance.is_nil())
|
||||
|| snapshot.protocol_version != crate::SCANNER_DIRTY_USAGE_SNAPSHOT_PROTOCOL_VERSION
|
||||
|| !snapshot.complete
|
||||
|| snapshot.buckets.len() > crate::SCANNER_DIRTY_USAGE_SNAPSHOT_MAX_ENTRIES
|
||||
|| snapshot.pending_bucket_count != u64::try_from(snapshot.buckets.len()).ok()?
|
||||
|| !(lower <= snapshot.generation && snapshot.generation <= upper && upper < u64::MAX)
|
||||
|| (lower == snapshot.generation && lower_pending != pending)
|
||||
|| (upper == snapshot.generation && upper_pending != pending)
|
||||
|| snapshot.buckets.iter().any(|(bucket, entry)| {
|
||||
bucket.is_empty()
|
||||
|| bucket == DATA_USAGE_ROOT
|
||||
|| entry.bucket_incarnation.is_nil()
|
||||
|| entry.generation == 0
|
||||
|| entry.generation > snapshot.generation
|
||||
})
|
||||
|| peers.insert(host, snapshot).is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
(peers.len() == expected.len() && expected.iter().all(|(host, _, _)| peers.contains_key(host))).then_some(Self {
|
||||
before,
|
||||
after,
|
||||
peers,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn activity_for_walk(&self) -> Arc<ScannerActivitySnapshot> {
|
||||
Arc::new(self.after.clone())
|
||||
}
|
||||
|
||||
pub(super) fn strict_remote_usage(&self) -> Option<VerifiedRemoteDirtyUsage> {
|
||||
if crate::scanner::scanner_peer_dirty_usage_moved(&self.before, &self.after).is_some() {
|
||||
return None;
|
||||
}
|
||||
let expected = self
|
||||
.peers
|
||||
.keys()
|
||||
.map(|host| {
|
||||
let (instance_id, generation, pending) = scanner_activity_dirty_usage_state_for_host(&self.before, host)?;
|
||||
Some((
|
||||
host.clone(),
|
||||
ScannerPeerDirtyUsageExpectation {
|
||||
instance_id: instance_id.to_string(),
|
||||
generation,
|
||||
pending,
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect::<Option<HashMap<_, _>>>()?;
|
||||
verified_remote_dirty_usage(&expected, self.peers.iter().map(|(host, peer)| (host.clone(), peer.clone())).collect())
|
||||
}
|
||||
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
pub(super) fn observation(&self, epoch: u64, cycle: u64) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"epoch": epoch,
|
||||
"cycle": cycle,
|
||||
"verified": true,
|
||||
"activity_before": crate::e2e_barrier::activity_observations(&self.before),
|
||||
"activity_after": crate::e2e_barrier::activity_observations(&self.after),
|
||||
"peers": self.peers.iter().map(|(host, peer)| serde_json::json!({
|
||||
"host": host,
|
||||
"owner_id": peer.owner_id,
|
||||
"instance_id": peer.instance_id,
|
||||
"generation": peer.generation,
|
||||
"protocol_version": peer.protocol_version,
|
||||
"complete": peer.complete,
|
||||
"pending_bucket_count": peer.pending_bucket_count,
|
||||
"buckets": peer.buckets.iter().map(|(bucket, entry)| serde_json::json!({
|
||||
"bucket": bucket,
|
||||
"bucket_incarnation": entry.bucket_incarnation,
|
||||
"generation": entry.generation,
|
||||
})).collect::<Vec<_>>(),
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn dirty_buckets(&self) -> HashSet<String> {
|
||||
self.peers.values().flat_map(|peer| peer.buckets.keys().cloned()).collect()
|
||||
}
|
||||
|
||||
pub(super) fn acknowledgements(&self) -> Vec<ScannerDirtyUsageAcknowledgement> {
|
||||
self.peers
|
||||
.iter()
|
||||
.filter(|(_, peer)| !peer.buckets.is_empty())
|
||||
.map(|(host, peer)| ScannerDirtyUsageAcknowledgement {
|
||||
host: host.clone(),
|
||||
instance_id: peer.instance_id.clone(),
|
||||
kind: ScannerDirtyUsageAcknowledgementKind::Scoped {
|
||||
owner_id: peer.owner_id.clone(),
|
||||
entries: peer
|
||||
.buckets
|
||||
.iter()
|
||||
.map(|(bucket, entry)| crate::storage_api::EcstoreScannerScopedDirtyUsageAckEntry {
|
||||
bucket: bucket.clone(),
|
||||
bucket_incarnation: entry.bucket_incarnation,
|
||||
generation: entry.generation,
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A complete producer proof includes clean peers even when it yields no ACK.
|
||||
/// The start and post-walk maps remain sealed until publication and lease
|
||||
/// validation; post-lease writes are retained by exact receiver comparison.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ScannerRemoteDirtyUsageCertification {
|
||||
pub(super) start: ScannerRemoteDirtyUsageWindow,
|
||||
pub(super) post_walk: ScannerRemoteDirtyUsageWindow,
|
||||
}
|
||||
|
||||
impl ScannerRemoteDirtyUsageCertification {
|
||||
pub(super) fn new(start: ScannerRemoteDirtyUsageWindow, post_walk: ScannerRemoteDirtyUsageWindow) -> Option<Self> {
|
||||
if !remote_generations_advance(&start.after, &post_walk.before)
|
||||
|| start.peers.len() != post_walk.peers.len()
|
||||
|| start.peers.iter().any(|(host, before)| {
|
||||
post_walk.peers.get(host).is_none_or(|after| {
|
||||
before.owner_id != after.owner_id
|
||||
|| before.instance_id != after.instance_id
|
||||
|| before.protocol_version != after.protocol_version
|
||||
|| before.generation > after.generation
|
||||
})
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(Self { start, post_walk })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn for_tests(
|
||||
activity: ScannerActivitySnapshot,
|
||||
peers: Vec<(String, EcstoreScannerPeerDirtyUsageSnapshot)>,
|
||||
) -> Option<Self> {
|
||||
let start = ScannerRemoteDirtyUsageWindow::verify(activity.clone(), peers.clone(), activity.clone())?;
|
||||
let post = ScannerRemoteDirtyUsageWindow::verify(activity.clone(), peers, activity)?;
|
||||
Self::new(start, post)
|
||||
}
|
||||
|
||||
pub(super) fn classify_buckets(
|
||||
&self,
|
||||
expected_sources: &HashSet<DataUsageCacheSource>,
|
||||
completions: &ScannerBucketCompletions,
|
||||
uncertified: &HashSet<String>,
|
||||
) -> (Vec<ScannerDirtyUsageAcknowledgement>, ScannerCyclePendingBuckets) {
|
||||
let mut buckets = self.start.dirty_buckets();
|
||||
buckets.extend(self.post_walk.dirty_buckets());
|
||||
let sources_complete = !expected_sources.is_empty()
|
||||
&& completions.len() == expected_sources.len()
|
||||
&& expected_sources.iter().all(|source| completions.contains_key(source));
|
||||
let pending = buckets
|
||||
.iter()
|
||||
.filter(|bucket| {
|
||||
!sources_complete
|
||||
|| uncertified.contains(*bucket)
|
||||
|| self.start.peers.iter().any(|(host, before)| {
|
||||
let start = before.buckets.get(*bucket);
|
||||
let post_walk = self.post_walk.peers[host].buckets.get(*bucket);
|
||||
start != post_walk
|
||||
|| start.is_some_and(|entry| {
|
||||
expected_sources.iter().any(|source| {
|
||||
completions.get(source).and_then(|set| set.get(*bucket)) != Some(&entry.bucket_incarnation)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect::<ScannerCyclePendingBuckets>();
|
||||
let acknowledgements = self
|
||||
.start
|
||||
.acknowledgements()
|
||||
.into_iter()
|
||||
.filter_map(|mut acknowledgement| {
|
||||
let ScannerDirtyUsageAcknowledgementKind::Scoped { entries, .. } = &mut acknowledgement.kind else {
|
||||
return None;
|
||||
};
|
||||
entries.retain(|entry| !pending.contains(&entry.bucket));
|
||||
(!entries.is_empty()).then_some(acknowledgement)
|
||||
})
|
||||
.collect();
|
||||
(acknowledgements, pending)
|
||||
}
|
||||
|
||||
pub(crate) fn matches_activity(&self, activity: &ScannerActivitySnapshot) -> bool {
|
||||
remote_generations_advance(&self.post_walk.after, activity)
|
||||
}
|
||||
|
||||
pub(crate) fn matches_publication_leases(&self, grants: &[EcstoreScannerPublicationLeaseGrant]) -> bool {
|
||||
if grants.len() != self.post_walk.peers.len() {
|
||||
return false;
|
||||
}
|
||||
let targets = scanner_activity_publication_lease_targets(&self.post_walk.after)
|
||||
.into_iter()
|
||||
.map(|(host, instance, movement)| (host, (instance, movement)))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut hosts = HashSet::new();
|
||||
grants.iter().all(|grant| {
|
||||
let Some(peer) = self.post_walk.peers.get(&grant.host) else {
|
||||
return false;
|
||||
};
|
||||
hosts.insert(grant.host.as_str())
|
||||
&& grant.lease.is_valid()
|
||||
&& !grant.lease.observational_only
|
||||
&& !grant.lease.token.is_nil()
|
||||
&& grant.lease.owner_id == peer.owner_id
|
||||
&& grant.lease.session_id == peer.instance_id
|
||||
&& targets.get(&grant.host).is_some_and(|(instance, movement)| {
|
||||
instance == &grant.lease.session_id && *movement == grant.lease.movement_generation
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const HOT: uuid::Uuid = uuid::Uuid::from_u128(71);
|
||||
const COLD: uuid::Uuid = uuid::Uuid::from_u128(72);
|
||||
|
||||
fn peers(generation: u64, hot_generation: u64) -> Vec<(String, EcstoreScannerPeerDirtyUsageSnapshot)> {
|
||||
["peer-a", "peer-b"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, host)| {
|
||||
let buckets = if index == 0 {
|
||||
BTreeMap::from([
|
||||
(
|
||||
"cold-bucket".to_string(),
|
||||
crate::storage_api::EcstoreScannerPeerDirtyUsageBucket {
|
||||
bucket_incarnation: COLD,
|
||||
generation: 8,
|
||||
},
|
||||
),
|
||||
(
|
||||
"hot-bucket".to_string(),
|
||||
crate::storage_api::EcstoreScannerPeerDirtyUsageBucket {
|
||||
bucket_incarnation: HOT,
|
||||
generation: hot_generation,
|
||||
},
|
||||
),
|
||||
])
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
};
|
||||
(
|
||||
host.to_string(),
|
||||
EcstoreScannerPeerDirtyUsageSnapshot {
|
||||
owner_id: uuid::Uuid::from_u128(101 + index as u128).simple().to_string(),
|
||||
instance_id: uuid::Uuid::from_u128(201 + index as u128).simple().to_string(),
|
||||
generation: if index == 0 { generation } else { 7 },
|
||||
pending_bucket_count: buckets.len() as u64,
|
||||
protocol_version: crate::SCANNER_DIRTY_USAGE_SNAPSHOT_PROTOCOL_VERSION,
|
||||
complete: true,
|
||||
buckets,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn activity(generation: u64) -> ScannerActivitySnapshot {
|
||||
peers(generation, generation)
|
||||
.into_iter()
|
||||
.map(|(host, peer)| {
|
||||
(
|
||||
host,
|
||||
crate::scanner::scanner_node_activity_for_tests(
|
||||
&peer.instance_id,
|
||||
peer.generation,
|
||||
peer.generation,
|
||||
!peer.buckets.is_empty(),
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn window(lower: u64, generation: u64, upper: u64) -> ScannerRemoteDirtyUsageWindow {
|
||||
ScannerRemoteDirtyUsageWindow::verify(activity(lower), peers(generation, generation), activity(upper))
|
||||
.expect("ordered complete producer window")
|
||||
}
|
||||
|
||||
fn completions() -> (HashSet<DataUsageCacheSource>, ScannerBucketCompletions) {
|
||||
let sources = HashSet::from([DataUsageCacheSource::new(0, 0), DataUsageCacheSource::new(0, 1)]);
|
||||
let completed = sources
|
||||
.iter()
|
||||
.map(|source| {
|
||||
(
|
||||
*source,
|
||||
HashMap::from([("cold-bucket".to_string(), COLD), ("hot-bucket".to_string(), HOT)]),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
(sources, completed)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hot_writes_cross_both_bounded_windows_while_the_clean_peer_stays_in_the_proof() {
|
||||
let start = window(9, 10, 11);
|
||||
assert!(start.strict_remote_usage().is_none(), "the relaxed window grants no segment reuse proof");
|
||||
let proof = ScannerRemoteDirtyUsageCertification::new(start, window(12, 13, 14)).expect("same producers");
|
||||
let (sources, completed) = completions();
|
||||
let (acks, pending) = proof.classify_buckets(&sources, &completed, &HashSet::new());
|
||||
assert_eq!(pending, BTreeSet::from(["hot-bucket".to_string()]));
|
||||
assert_eq!(acks.len(), 1);
|
||||
assert_eq!(proof.post_walk.peers.len(), 2);
|
||||
assert!(proof.post_walk.peers["peer-b"].buckets.is_empty());
|
||||
let ScannerDirtyUsageAcknowledgementKind::Scoped { entries, .. } = &acks[0].kind else {
|
||||
panic!("scoped ACK")
|
||||
};
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(
|
||||
(entries[0].bucket.as_str(), entries[0].bucket_incarnation, entries[0].generation),
|
||||
("cold-bucket", COLD, 8)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn producer_windows_reject_missing_duplicate_stale_future_overflow_and_incomplete_evidence() {
|
||||
for case in [
|
||||
"missing_peer",
|
||||
"duplicate_peer",
|
||||
"old_snapshot",
|
||||
"future_snapshot",
|
||||
"overflow",
|
||||
"owner",
|
||||
"instance",
|
||||
"protocol",
|
||||
"incomplete",
|
||||
"count",
|
||||
"future_entry",
|
||||
"nil_incarnation",
|
||||
"empty_bucket",
|
||||
"entry_limit",
|
||||
"pending",
|
||||
] {
|
||||
let mut snapshots = peers(10, 10);
|
||||
let before = activity(9);
|
||||
let mut after = activity(11);
|
||||
match case {
|
||||
"missing_peer" => {
|
||||
snapshots.pop();
|
||||
}
|
||||
"duplicate_peer" => {
|
||||
snapshots[1] = snapshots[0].clone();
|
||||
}
|
||||
"old_snapshot" => snapshots[0].1.generation = 8,
|
||||
"future_snapshot" => snapshots[0].1.generation = 12,
|
||||
"overflow" => after = activity(u64::MAX),
|
||||
"owner" => snapshots[0].1.owner_id = uuid::Uuid::nil().to_string(),
|
||||
"instance" => snapshots[0].1.instance_id = uuid::Uuid::from_u128(999).to_string(),
|
||||
"protocol" => snapshots[0].1.protocol_version += 1,
|
||||
"incomplete" => snapshots[0].1.complete = false,
|
||||
"count" => snapshots[0].1.pending_bucket_count += 1,
|
||||
"future_entry" => snapshots[0].1.buckets.get_mut("cold-bucket").expect("cold").generation = 11,
|
||||
"nil_incarnation" => {
|
||||
snapshots[0]
|
||||
.1
|
||||
.buckets
|
||||
.get_mut("cold-bucket")
|
||||
.expect("cold")
|
||||
.bucket_incarnation = uuid::Uuid::nil()
|
||||
}
|
||||
"empty_bucket" => {
|
||||
let entry = snapshots[0].1.buckets.remove("cold-bucket").expect("cold");
|
||||
snapshots[0].1.buckets.insert(String::new(), entry);
|
||||
}
|
||||
"entry_limit" => {
|
||||
let peer = &mut snapshots[0].1;
|
||||
peer.buckets = (0..=crate::SCANNER_DIRTY_USAGE_SNAPSHOT_MAX_ENTRIES)
|
||||
.map(|index| {
|
||||
(
|
||||
format!("bucket-{index}"),
|
||||
crate::storage_api::EcstoreScannerPeerDirtyUsageBucket {
|
||||
bucket_incarnation: COLD,
|
||||
generation: 8,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
peer.pending_bucket_count = peer.buckets.len() as u64;
|
||||
}
|
||||
"pending" => {
|
||||
snapshots[0].1.generation = 11;
|
||||
snapshots[0].1.buckets.clear();
|
||||
snapshots[0].1.pending_bucket_count = 0;
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert!(ScannerRemoteDirtyUsageWindow::verify(before, snapshots, after).is_none(), "{case}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certification_rejects_owner_replacement_and_a_generation_rollback_between_windows() {
|
||||
let mut changed = window(12, 13, 14);
|
||||
changed.peers.get_mut("peer-b").expect("clean peer").owner_id = uuid::Uuid::from_u128(999).to_string();
|
||||
assert!(ScannerRemoteDirtyUsageCertification::new(window(9, 10, 11), changed).is_none());
|
||||
assert!(ScannerRemoteDirtyUsageCertification::new(window(9, 10, 11), window(8, 9, 10)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certification_requires_actual_coverage_and_the_same_incarnation_on_every_set() {
|
||||
let proof = ScannerRemoteDirtyUsageCertification::new(window(10, 10, 10), window(10, 10, 10)).expect("stable peers");
|
||||
for case in ["missing_set", "missing_bucket", "wrong_incarnation", "partial"] {
|
||||
let (sources, mut completed) = completions();
|
||||
let mut uncertified = HashSet::new();
|
||||
match case {
|
||||
"missing_set" => {
|
||||
completed.remove(&DataUsageCacheSource::new(0, 1));
|
||||
}
|
||||
"missing_bucket" => {
|
||||
completed
|
||||
.get_mut(&DataUsageCacheSource::new(0, 1))
|
||||
.expect("set")
|
||||
.remove("cold-bucket");
|
||||
}
|
||||
"wrong_incarnation" => {
|
||||
completed
|
||||
.get_mut(&DataUsageCacheSource::new(0, 1))
|
||||
.expect("set")
|
||||
.insert("cold-bucket".to_string(), uuid::Uuid::from_u128(999));
|
||||
}
|
||||
"partial" => {
|
||||
uncertified.insert("cold-bucket".to_string());
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let (acks, pending) = proof.classify_buckets(&sources, &completed, &uncertified);
|
||||
assert!(pending.contains("cold-bucket"), "{case}");
|
||||
assert!(acks.iter().all(|ack| matches!(&ack.kind, ScannerDirtyUsageAcknowledgementKind::Scoped { entries, .. } if entries.iter().all(|entry| entry.bucket != "cold-bucket"))), "{case}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bucket_changed_on_one_peer_cannot_be_acknowledged_on_another() {
|
||||
let start = window(10, 10, 10);
|
||||
let mut post_peers = peers(10, 10);
|
||||
post_peers[1].1.generation = 8;
|
||||
post_peers[1].1.pending_bucket_count = 1;
|
||||
post_peers[1].1.buckets.insert(
|
||||
"cold-bucket".to_string(),
|
||||
crate::storage_api::EcstoreScannerPeerDirtyUsageBucket {
|
||||
bucket_incarnation: COLD,
|
||||
generation: 8,
|
||||
},
|
||||
);
|
||||
let mut after = activity(10);
|
||||
after.insert(
|
||||
"peer-b".to_string(),
|
||||
crate::scanner::scanner_node_activity_for_tests(&post_peers[1].1.instance_id, 8, 8, true),
|
||||
);
|
||||
let post = ScannerRemoteDirtyUsageWindow::verify(activity(10), post_peers, after).expect("clean peer became dirty");
|
||||
let proof = ScannerRemoteDirtyUsageCertification::new(start, post).expect("same producers");
|
||||
let (sources, completed) = completions();
|
||||
let (acks, pending) = proof.classify_buckets(&sources, &completed, &HashSet::new());
|
||||
assert!(pending.contains("cold-bucket"));
|
||||
assert!(acks.iter().all(|ack| matches!(&ack.kind, ScannerDirtyUsageAcknowledgementKind::Scoped { entries, .. } if entries.iter().all(|entry| entry.bucket != "cold-bucket"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_window_trailing_activity_invalidates_same_cycle_current_cache_without_segment_reuse() {
|
||||
let window = window(9, 10, 11);
|
||||
let old_plan = scanner_bucket_cache_digest(
|
||||
DataUsageScanPlanDigest(crate::scanner::scanner_activity_snapshot_digest(&window.before)),
|
||||
None,
|
||||
);
|
||||
let new_plan = scanner_bucket_cache_digest(
|
||||
DataUsageScanPlanDigest(crate::scanner::scanner_activity_snapshot_digest(&window.activity_for_walk())),
|
||||
None,
|
||||
);
|
||||
let source = DataUsageCacheSource::new(0, 0);
|
||||
let mut cache = DataUsageCache {
|
||||
info: DataUsageCacheInfo {
|
||||
name: "cold-bucket".to_string(),
|
||||
next_cycle: 7,
|
||||
leader_epoch: 11,
|
||||
source: Some(source),
|
||||
last_update: Some(SystemTime::UNIX_EPOCH),
|
||||
snapshot_complete: true,
|
||||
scan_plan_digest: Some(old_plan),
|
||||
cache_key_format: DATA_USAGE_CACHE_KEY_FORMAT,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
cache.replace("cold-bucket", "", DataUsageEntry::default());
|
||||
let options = || DataUsageCacheReuseOptions {
|
||||
require_source: true,
|
||||
tier_registry_generation: None,
|
||||
checkpoint_identity: None,
|
||||
checkpoint_plan_digest: None,
|
||||
};
|
||||
assert!(matches!(
|
||||
current_cache_root_or_prepare_with_generation(&mut cache.clone(), "cold-bucket", source, 7, 11, old_plan, options()),
|
||||
DataUsageCacheScanState::Current(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
current_cache_root_or_prepare_with_generation(&mut cache, "cold-bucket", source, 7, 11, new_plan, options()),
|
||||
DataUsageCacheScanState::Prepared {
|
||||
outcome: DataUsageCachePrepareOutcome::Reset,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(window.strict_remote_usage().is_none());
|
||||
}
|
||||
}
|
||||
@@ -1330,6 +1330,7 @@ async fn scoped_scan_production_entry_preserves_deep_and_full_maintenance_work()
|
||||
requires_full_scan,
|
||||
resolved_scope_observer: Some(observer),
|
||||
post_walk_gate: None,
|
||||
peer_observations: None,
|
||||
service_cohort: None,
|
||||
},
|
||||
),
|
||||
@@ -1421,6 +1422,7 @@ async fn scoped_scan_same_cycle_maintenance_rewalks_after_root_delivery_failure(
|
||||
requires_full_scan: false,
|
||||
resolved_scope_observer: None,
|
||||
post_walk_gate: None,
|
||||
peer_observations: None,
|
||||
service_cohort: None,
|
||||
},
|
||||
),
|
||||
@@ -1475,6 +1477,7 @@ async fn scoped_scan_same_cycle_maintenance_rewalks_after_root_delivery_failure(
|
||||
requires_full_scan,
|
||||
resolved_scope_observer: None,
|
||||
post_walk_gate: None,
|
||||
peer_observations: None,
|
||||
service_cohort: None,
|
||||
},
|
||||
),
|
||||
@@ -2324,6 +2327,7 @@ async fn set_snapshot_reuse_requires_execution_identity_and_fences_stale_writers
|
||||
publication_epoch: Some(epoch),
|
||||
dirty_usage_buckets: Arc::new(HashMap::new()),
|
||||
bucket_failures: ScannerBucketFailureState::default(),
|
||||
bucket_completions: None,
|
||||
pending_maintenance_work: Arc::new(AtomicBool::new(false)),
|
||||
cache_cycle_floor: Arc::new(AtomicU64::new(8)),
|
||||
cold_zero_walk_reuse_observed: Arc::new(AtomicBool::new(false)),
|
||||
@@ -3241,6 +3245,7 @@ async fn distributed_scoped_scan_falls_back_when_remote_ack_exceeds_protocol_bat
|
||||
store.as_ref(),
|
||||
true,
|
||||
super::io_cycle::ScannerBucketScopeResolution {
|
||||
remote_window: None,
|
||||
requested_scope: ScannerBucketScanScope::default(),
|
||||
baseline_proof: ScannerCacheBaselineProof {
|
||||
authoritative_data: Some(&baseline),
|
||||
@@ -3300,6 +3305,7 @@ async fn distributed_scoped_scan_falls_back_when_remote_scoped_ack_capability_is
|
||||
store.as_ref(),
|
||||
true,
|
||||
super::io_cycle::ScannerBucketScopeResolution {
|
||||
remote_window: None,
|
||||
requested_scope: ScannerBucketScanScope::default(),
|
||||
baseline_proof: ScannerCacheBaselineProof {
|
||||
authoritative_data: Some(&baseline),
|
||||
@@ -4868,6 +4874,7 @@ async fn replication_maintenance_refreshes_compacted_stats_across_cycles_and_ret
|
||||
service_cohort: None,
|
||||
resolved_scope_observer: None,
|
||||
post_walk_gate: None,
|
||||
peer_observations: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -4975,6 +4982,7 @@ async fn replication_maintenance_refreshes_compacted_stats_across_cycles_and_ret
|
||||
service_cohort: None,
|
||||
resolved_scope_observer: None,
|
||||
post_walk_gate: None,
|
||||
peer_observations: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -5122,6 +5130,7 @@ async fn run_full_cycle(store: &Arc<ECStore>, cycle: u64) -> ScannerCycleStatus
|
||||
resolved_scope_observer: None,
|
||||
service_cohort: None,
|
||||
post_walk_gate: None,
|
||||
peer_observations: None,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -54,6 +54,7 @@ async fn run_full_cycle(
|
||||
service_cohort: None,
|
||||
resolved_scope_observer: None,
|
||||
post_walk_gate: None,
|
||||
peer_observations: None,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -140,6 +140,7 @@ async fn scan_plan(set: &Arc<SetDisks>, names: &[&str], cycle: u64) -> ScannerBu
|
||||
),
|
||||
dirty_usage_buckets: Arc::new(dirty_usage_buckets_for_tests()),
|
||||
bucket_failures: ScannerBucketFailureState::default(),
|
||||
bucket_completions: None,
|
||||
pending_maintenance_work: Arc::new(AtomicBool::new(false)),
|
||||
cache_cycle_floor: Arc::new(AtomicU64::new(cycle)),
|
||||
cold_zero_walk_reuse_observed: Arc::new(AtomicBool::new(false)),
|
||||
|
||||
@@ -47,6 +47,7 @@ async fn run_cycle(
|
||||
service_cohort: None,
|
||||
resolved_scope_observer: Some(observer),
|
||||
post_walk_gate: None,
|
||||
peer_observations: None,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -57,6 +57,7 @@ async fn run_cycle(store: &Arc<ECStore>, cycle: u64, options: CycleOptions) -> (
|
||||
service_cohort: None,
|
||||
resolved_scope_observer: options.resolved_scope_observer,
|
||||
post_walk_gate: None,
|
||||
peer_observations: None,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ async fn put_fixture_object(store: &Arc<ECStore>, bucket: &str, object: &str) {
|
||||
struct CycleHooks {
|
||||
resolved_scope_observer: Option<tokio::sync::oneshot::Sender<ScannerBucketScanScope>>,
|
||||
post_walk_gate: Option<tokio::sync::oneshot::Receiver<()>>,
|
||||
peer_observations: Option<super::super::io_cycle::ScannerCyclePeerObservations>,
|
||||
}
|
||||
|
||||
async fn run_cycle(
|
||||
@@ -31,6 +32,7 @@ async fn run_cycle(
|
||||
requires_full_scan: bool,
|
||||
hooks: CycleHooks,
|
||||
) -> (ScannerCycleResult, DataUsageInfo) {
|
||||
let peer_cycle = hooks.peer_observations.is_some();
|
||||
let ctx = CancellationToken::new();
|
||||
let budget = ScannerCycleBudget::new(&ctx, ScannerCycleBudgetConfig::default());
|
||||
let (updates, mut receiver) = mpsc::channel(1);
|
||||
@@ -52,12 +54,20 @@ async fn run_cycle(
|
||||
service_cohort: None,
|
||||
resolved_scope_observer: hooks.resolved_scope_observer,
|
||||
post_walk_gate: hooks.post_walk_gate,
|
||||
peer_observations: hooks.peer_observations,
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("cycle should finish within the test deadline")
|
||||
.expect("cycle should succeed");
|
||||
if peer_cycle {
|
||||
assert_eq!(
|
||||
result.status,
|
||||
ScannerCycleStatus::Complete,
|
||||
"one remote hot bucket cannot supersede certified cold buckets"
|
||||
);
|
||||
}
|
||||
let snapshot = receiver.recv().await.expect("cycle should publish a snapshot");
|
||||
assert!(receiver.recv().await.is_none(), "a cycle publishes one terminal snapshot");
|
||||
(result, snapshot)
|
||||
@@ -91,6 +101,7 @@ async fn write_during_walk_leaves_only_that_bucket_pending_and_scopes_the_next_c
|
||||
CycleHooks {
|
||||
resolved_scope_observer: None,
|
||||
post_walk_gate: None,
|
||||
peer_observations: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -128,6 +139,7 @@ async fn write_during_walk_leaves_only_that_bucket_pending_and_scopes_the_next_c
|
||||
CycleHooks {
|
||||
resolved_scope_observer: Some(observer),
|
||||
post_walk_gate: Some(gate),
|
||||
peer_observations: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -175,6 +187,7 @@ async fn write_during_walk_leaves_only_that_bucket_pending_and_scopes_the_next_c
|
||||
CycleHooks {
|
||||
resolved_scope_observer: Some(observer),
|
||||
post_walk_gate: None,
|
||||
peer_observations: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -226,6 +239,7 @@ async fn pending_put_tail_across_the_walk_no_longer_vetoes_the_cycle() {
|
||||
CycleHooks {
|
||||
resolved_scope_observer: None,
|
||||
post_walk_gate: None,
|
||||
peer_observations: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -285,6 +299,7 @@ async fn guardrail_veto_still_supersedes_the_cycle_onto_the_observed_route() {
|
||||
CycleHooks {
|
||||
resolved_scope_observer: Some(observer),
|
||||
post_walk_gate: Some(gate),
|
||||
peer_observations: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -306,3 +321,230 @@ async fn guardrail_veto_still_supersedes_the_cycle_onto_the_observed_route() {
|
||||
);
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn remote_hot_bucket_keeps_cold_buckets_certified_and_scopes_the_next_cycle() {
|
||||
run_remote_certification_cycle(false).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn a_verified_empty_ack_on_a_full_scope_keeps_the_remote_hot_bucket_pending() {
|
||||
run_remote_certification_cycle(true).await;
|
||||
}
|
||||
|
||||
async fn run_remote_certification_cycle(empty_ack: bool) {
|
||||
let (_temp_dir, store) = setup_local_scanner_store(1, 1).await;
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
let mut incarnations = BTreeMap::new();
|
||||
for bucket in BUCKETS {
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("fixture bucket");
|
||||
put_fixture_object(&store, bucket, "initial").await;
|
||||
record_dirty_usage_bucket(bucket);
|
||||
incarnations.insert(
|
||||
bucket.to_string(),
|
||||
store.all_set_disks()[0]
|
||||
.find_bucket_incarnation_id_from_disk(bucket)
|
||||
.await
|
||||
.expect("read actual incarnation")
|
||||
.expect("fixture bucket has an incarnation"),
|
||||
);
|
||||
}
|
||||
wait_for_namespace_commit_tails(store.as_ref()).await;
|
||||
let peers = Arc::new(StdMutex::new(BTreeMap::from([
|
||||
(
|
||||
"peer-a:9000".to_string(),
|
||||
peer_dirty_usage_snapshot(&Uuid::from_u128(101).to_string(), 7, true, &[]),
|
||||
),
|
||||
(
|
||||
"peer-b:9000".to_string(),
|
||||
peer_dirty_usage_snapshot(&Uuid::from_u128(102).to_string(), 7, true, &[]),
|
||||
),
|
||||
])));
|
||||
// These are the RPC observations only. Bucket listing, traversal, cache
|
||||
// coverage, classification and candidate production use the real cycle.
|
||||
let activity_peers = Arc::clone(&peers);
|
||||
let dirty_peers = Arc::clone(&peers);
|
||||
let observations = super::super::io_cycle::ScannerCyclePeerObservations {
|
||||
activity: Arc::new(move || {
|
||||
activity_peers
|
||||
.lock()
|
||||
.expect("peer fixture lock")
|
||||
.iter()
|
||||
.map(|(host, peer)| {
|
||||
(
|
||||
host.clone(),
|
||||
crate::scanner::scanner_node_activity_for_tests(
|
||||
&peer.instance_id,
|
||||
peer.generation,
|
||||
peer.generation,
|
||||
!peer.buckets.is_empty(),
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
dirty_usage: Arc::new(move || {
|
||||
dirty_peers
|
||||
.lock()
|
||||
.expect("peer fixture lock")
|
||||
.iter()
|
||||
.map(|(host, snapshot)| (host.clone(), snapshot.clone()))
|
||||
.collect()
|
||||
}),
|
||||
scoped_ack_capable: true,
|
||||
};
|
||||
let (result, baseline) = run_cycle(
|
||||
&store,
|
||||
1,
|
||||
None,
|
||||
true,
|
||||
CycleHooks {
|
||||
resolved_scope_observer: None,
|
||||
post_walk_gate: None,
|
||||
peer_observations: Some(observations.clone()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result.status, ScannerCycleStatus::Complete);
|
||||
assert!(result.clear_verified_usage().is_empty());
|
||||
assert!(baseline.is_complete_bucket_usage_snapshot());
|
||||
let initial_dirty = if empty_ack {
|
||||
vec!["hot-bucket"]
|
||||
} else {
|
||||
vec!["cold-bucket", "idle-bucket"]
|
||||
};
|
||||
for bucket in initial_dirty {
|
||||
put_fixture_object(&store, bucket, "second").await;
|
||||
let mut locked = peers.lock().expect("peer fixture lock");
|
||||
let peer = locked.get_mut("peer-a:9000").expect("remote producer exists");
|
||||
peer.generation += 1;
|
||||
peer.buckets.insert(
|
||||
bucket.to_string(),
|
||||
crate::storage_api::EcstoreScannerPeerDirtyUsageBucket {
|
||||
bucket_incarnation: incarnations[bucket],
|
||||
generation: peer.generation,
|
||||
},
|
||||
);
|
||||
peer.pending_bucket_count = u64::try_from(peer.buckets.len()).expect("bounded fixture");
|
||||
}
|
||||
wait_for_namespace_commit_tails(store.as_ref()).await;
|
||||
let (observer, resolved_scope) = tokio::sync::oneshot::channel();
|
||||
let (release, gate) = tokio::sync::oneshot::channel();
|
||||
let writer_store = Arc::clone(&store);
|
||||
let writer_peers = Arc::clone(&peers);
|
||||
let hot_incarnation = incarnations["hot-bucket"];
|
||||
let writer = tokio::spawn(async move {
|
||||
let scope = resolved_scope.await.expect("real cycle resolved its scope");
|
||||
put_fixture_object(&writer_store, "hot-bucket", "during-walk").await;
|
||||
{
|
||||
let mut locked = writer_peers.lock().expect("peer fixture lock");
|
||||
let peer = locked.get_mut("peer-a:9000").expect("remote producer exists");
|
||||
peer.generation += 1;
|
||||
peer.buckets.insert(
|
||||
"hot-bucket".to_string(),
|
||||
crate::storage_api::EcstoreScannerPeerDirtyUsageBucket {
|
||||
bucket_incarnation: hot_incarnation,
|
||||
generation: peer.generation,
|
||||
},
|
||||
);
|
||||
peer.pending_bucket_count = u64::try_from(peer.buckets.len()).expect("bounded fixture");
|
||||
}
|
||||
wait_for_namespace_commit_tails(writer_store.as_ref()).await;
|
||||
release.send(()).expect("cycle awaits its actual post-walk proof");
|
||||
scope
|
||||
});
|
||||
let (result, published) = run_cycle(
|
||||
&store,
|
||||
2,
|
||||
(!empty_ack).then(|| baseline_bytes(&baseline)),
|
||||
false,
|
||||
CycleHooks {
|
||||
resolved_scope_observer: Some(observer),
|
||||
post_walk_gate: Some(gate),
|
||||
peer_observations: Some(observations.clone()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let scope = writer.await.expect("writer completed");
|
||||
if empty_ack {
|
||||
assert!(scope.is_default(), "this case exercises the full-scope generation fallback");
|
||||
} else {
|
||||
assert_eq!(
|
||||
scope.selected_buckets_for_tests(),
|
||||
Some(&HashSet::from(["cold-bucket".to_string(), "idle-bucket".to_string()]))
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
result.status,
|
||||
ScannerCycleStatus::Complete,
|
||||
"one remote hot bucket cannot supersede certified cold buckets"
|
||||
);
|
||||
assert_eq!(published.usage_snapshot_pending_buckets, vec!["hot-bucket".to_string()]);
|
||||
assert_eq!(published.usage_snapshot_converged, Some(false));
|
||||
assert!(!published.usage_snapshot_observational);
|
||||
assert_eq!(published.buckets_usage["cold-bucket"].objects_count, if empty_ack { 1 } else { 2 });
|
||||
assert_eq!(published.buckets_usage["idle-bucket"].objects_count, if empty_ack { 1 } else { 2 });
|
||||
if !empty_ack {
|
||||
assert_eq!(published.buckets_usage["hot-bucket"].objects_count, 1);
|
||||
}
|
||||
assert!(result.has_remote_dirty_usage_pending());
|
||||
let acknowledgements = &result.remote_dirty_usage_acknowledgements;
|
||||
if empty_ack {
|
||||
assert!(acknowledgements.is_empty(), "a verified empty scoped ACK cannot become a generation ACK");
|
||||
} else {
|
||||
assert_eq!(acknowledgements.len(), 1, "the clean peer is proved without a fabricated ACK");
|
||||
let crate::scanner::ScannerDirtyUsageAcknowledgementKind::Scoped { owner_id, entries } = &acknowledgements[0].kind else {
|
||||
panic!("certified remote buckets require exact scoped ACKs");
|
||||
};
|
||||
assert_eq!(
|
||||
entries.iter().map(|entry| entry.bucket.as_str()).collect::<BTreeSet<_>>(),
|
||||
BTreeSet::from(["cold-bucket", "idle-bucket"])
|
||||
);
|
||||
{
|
||||
let mut locked = peers.lock().expect("peer fixture lock");
|
||||
let peer = locked
|
||||
.get_mut(&acknowledgements[0].host)
|
||||
.expect("ACK targets the original peer");
|
||||
assert_eq!(&peer.owner_id, owner_id);
|
||||
assert_eq!(peer.instance_id, acknowledgements[0].instance_id);
|
||||
// Model the RPC receiver only after asserting the production sender's
|
||||
// exact scope; this does not claim runtime publication/receiver proof.
|
||||
for entry in entries {
|
||||
let current = peer.buckets.get(&entry.bucket).expect("ACK names an observed tuple");
|
||||
assert_eq!(
|
||||
(current.bucket_incarnation, current.generation),
|
||||
(entry.bucket_incarnation, entry.generation)
|
||||
);
|
||||
peer.buckets.remove(&entry.bucket);
|
||||
}
|
||||
peer.pending_bucket_count = u64::try_from(peer.buckets.len()).expect("bounded fixture");
|
||||
}
|
||||
}
|
||||
let _ = result.clear_verified_usage();
|
||||
let (observer, resolved_scope) = tokio::sync::oneshot::channel();
|
||||
let (result, next) = run_cycle(
|
||||
&store,
|
||||
3,
|
||||
Some(baseline_bytes(&published)),
|
||||
false,
|
||||
CycleHooks {
|
||||
resolved_scope_observer: Some(observer),
|
||||
post_walk_gate: None,
|
||||
peer_observations: Some(observations),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
resolved_scope.await.expect("next cycle scope").selected_buckets_for_tests(),
|
||||
Some(&HashSet::from(["hot-bucket".to_string()]))
|
||||
);
|
||||
assert_eq!(result.status, ScannerCycleStatus::Complete);
|
||||
assert_eq!(next.buckets_usage["hot-bucket"].objects_count, if empty_ack { 3 } else { 2 });
|
||||
assert_eq!(next.usage_snapshot_converged, Some(true));
|
||||
clear_dirty_usage_buckets_for_tests();
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ async fn run_entry(
|
||||
service_cohort: None,
|
||||
resolved_scope_observer: Some(observer),
|
||||
post_walk_gate: None,
|
||||
peer_observations: None,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -74,6 +74,7 @@ async fn run_cohort_cycle(
|
||||
service_cohort: Some(cohort),
|
||||
resolved_scope_observer: None,
|
||||
post_walk_gate: None,
|
||||
peer_observations: None,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -108,8 +108,10 @@ pub(crate) use rustfs_ecstore::api::layout::{
|
||||
EndpointServerPools as EcstoreEndpointServerPools, Endpoints as EcstoreEndpoints, PoolEndpoints as EcstorePoolEndpoints,
|
||||
SetupType as EcstoreSetupType,
|
||||
};
|
||||
pub(crate) use rustfs_ecstore::api::notification::NotificationSys as EcstoreNotificationSys;
|
||||
pub(crate) use rustfs_ecstore::api::notification::scanner_peer_transport_error_message_is_retryable;
|
||||
pub(crate) use rustfs_ecstore::api::notification::{
|
||||
NotificationSys as EcstoreNotificationSys, ScannerPublicationLeaseGrant as EcstoreScannerPublicationLeaseGrant,
|
||||
};
|
||||
pub(crate) use rustfs_ecstore::api::object::{
|
||||
SCANNER_PUBLICATION_LEASE_FENCE_METADATA_KEY, ScannerPublicationCommitScope, ScannerPublicationCommitState,
|
||||
};
|
||||
@@ -118,8 +120,6 @@ pub(crate) use rustfs_ecstore::api::rebalance::{
|
||||
RebalStatus as EcstoreRebalStatus, RebalanceInfo as EcstoreRebalanceInfo, RebalanceMeta as EcstoreRebalanceMeta,
|
||||
RebalanceStats as EcstoreRebalanceStats,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::rpc::ScannerPeerDirtyUsageBucket as EcstoreScannerPeerDirtyUsageBucket;
|
||||
pub(crate) use rustfs_ecstore::api::rpc::{
|
||||
ScannerBucketListing as EcstoreScannerBucketListing,
|
||||
ScannerDirtyUsageAcknowledgement as EcstoreScannerDirtyUsageAcknowledgement,
|
||||
@@ -127,6 +127,10 @@ pub(crate) use rustfs_ecstore::api::rpc::{
|
||||
ScannerScopedDirtyUsageAckEntry as EcstoreScannerScopedDirtyUsageAckEntry,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::rpc::{
|
||||
ScannerPeerDirtyUsageBucket as EcstoreScannerPeerDirtyUsageBucket, ScannerPublicationLease as EcstoreScannerPublicationLease,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::runtime::InstanceContext as EcstoreInstanceContext;
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::runtime::set_object_store_resolver as ecstore_set_object_store_resolver;
|
||||
@@ -365,6 +369,11 @@ pub(crate) mod scan {
|
||||
SCANNER_DIRTY_USAGE_SNAPSHOT_PROTOCOL_VERSION, SCANNER_DIRTY_USAGE_SNAPSHOT_RPC_MAX_MESSAGE_SIZE,
|
||||
SCANNER_SCOPED_DIRTY_USAGE_ACK_MAX_ENTRIES,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use super::{
|
||||
EcstoreScannerPeerDirtyUsageBucket, EcstoreScannerPeerDirtyUsageSnapshot, EcstoreScannerPublicationLease,
|
||||
EcstoreScannerPublicationLeaseGrant,
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) mod scanner_io {
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ license = []
|
||||
io-scheduler-debug = [] # Enable debug information in I/O scheduler
|
||||
tracing-chunk-debug = [] # Enable per-chunk tracing in data plane (high noise, for debugging only)
|
||||
full = ["metrics-gpu", "ftps", "swift", "webdav", "sftp", "pyroscope", "gcs"]
|
||||
e2e-test-hooks = ["rustfs-ecstore/e2e-test-hooks"]
|
||||
e2e-test-hooks = ["rustfs-ecstore/e2e-test-hooks", "rustfs-scanner/e2e-test-hooks"]
|
||||
# Shortens Connect credentials only in debug E2E builds.
|
||||
connect-e2e-short-credentials = []
|
||||
# Builds the dedicated rustfs-cli-e2e target with a build-time public enrollment root.
|
||||
|
||||
@@ -1398,8 +1398,13 @@ impl scanner_control_service_server::ScannerControlService for NodeService {
|
||||
.zip(&request.entries)
|
||||
.map(|(guard, entry)| (guard, entry.generation))
|
||||
.collect::<Vec<_>>();
|
||||
rustfs_scanner::acknowledge_scoped_dirty_usage(&request.instance_id, &entries, request.probe_only)
|
||||
.map_err(|err| Status::failed_precondition(err.to_string()))
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
observe_scoped_dirty_usage_ack("ack_before_apply", &request, None).await?;
|
||||
let cleared = rustfs_scanner::acknowledge_scoped_dirty_usage(&request.instance_id, &entries, request.probe_only)
|
||||
.map_err(|err| Status::failed_precondition(err.to_string()))?;
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
observe_scoped_dirty_usage_ack("ack_after_apply", &request, Some(cleared)).await?;
|
||||
Ok::<_, Status>(cleared)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Status::deadline_exceeded("scoped dirty usage incarnation validation timed out"))??;
|
||||
@@ -1422,6 +1427,44 @@ impl scanner_control_service_server::ScannerControlService for NodeService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture the real compare-and-remove boundary while the production
|
||||
/// incarnation fences are still held. Capability probes never pause here.
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
async fn observe_scoped_dirty_usage_ack(
|
||||
stage: &str,
|
||||
request: &ScannerScopedDirtyUsageAckRequest,
|
||||
cleared: Option<u64>,
|
||||
) -> Result<(), Status> {
|
||||
if request.probe_only {
|
||||
return Ok(());
|
||||
}
|
||||
let dirty =
|
||||
rustfs_scanner::scanner_dirty_usage_snapshot(rustfs_protos::scoped_dirty_usage::SCOPED_DIRTY_USAGE_MAX_ENTRIES as usize);
|
||||
let detail = serde_json::json!({
|
||||
"owner_id": request.owner_id,
|
||||
"instance_id": request.instance_id,
|
||||
"probe_only": request.probe_only,
|
||||
"entries": request.entries.iter().map(|entry| serde_json::json!({
|
||||
"bucket": entry.bucket,
|
||||
"bucket_incarnation": Uuid::from_slice(entry.bucket_incarnation.as_ref()).ok().map(|value| value.to_string()),
|
||||
"generation": entry.generation,
|
||||
})).collect::<Vec<_>>(),
|
||||
"cleared": cleared,
|
||||
"dirty_snapshot": {
|
||||
"generation": dirty.generation,
|
||||
"pending_bucket_count": dirty.pending_bucket_count,
|
||||
"complete": dirty.complete,
|
||||
"buckets": dirty.buckets.iter().map(|bucket| serde_json::json!({
|
||||
"bucket": bucket.bucket,
|
||||
"generation": bucket.generation,
|
||||
})).collect::<Vec<_>>(),
|
||||
},
|
||||
});
|
||||
rustfs_scanner::e2e_barrier::checkpoint(stage, detail)
|
||||
.await
|
||||
.map_err(|error| Status::internal(format!("scoped dirty usage test barrier failed: {error}")))
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl Node for NodeService {
|
||||
async fn ping(&self, request: Request<PingRequest>) -> Result<Response<PingResponse>, Status> {
|
||||
|
||||
Reference in New Issue
Block a user