mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-07 20:46:11 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0486ca9877 | |||
| 54716aa61c | |||
| 1a5e2b6256 | |||
| 474fcf78fb | |||
| 752d4a81ab | |||
| 0686277ee4 | |||
| 7373a5902e | |||
| e95a0ed6d9 | |||
| f0e0f5307d | |||
| 6855640192 | |||
| f11697d6e2 | |||
| 736b6a366c | |||
| 7b40b9503b | |||
| e22b879996 | |||
| b0dac1ac24 | |||
| 90e3ed701e | |||
| fd92853ac4 | |||
| bc0f608431 | |||
| f20a575994 | |||
| cc91483fac | |||
| c8fc50ada2 | |||
| 9badf3939c | |||
| c71c686e2a | |||
| 45f57ca57a | |||
| 265f429358 | |||
| 47f640f23e | |||
| 237c96dd3b | |||
| 647fe1a292 | |||
| 0c6314babc | |||
| fc76099280 | |||
| 899f81f3ad | |||
| 37bda24e1c | |||
| 4c2a0cdf9a | |||
| b370e746a0 | |||
| 95e6c89c1c | |||
| aafa7e2b7f | |||
| 5211b56277 |
@@ -47,6 +47,7 @@ script-tests: ## Run shell script tests
|
||||
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_object_data_cache_follower_samples.py --self-test
|
||||
./scripts/validate_object_data_cache_cold_stampede.sh --self-test
|
||||
./scripts/run_scanner_heal_evidence_case.sh --self-test
|
||||
|
||||
.PHONY: test
|
||||
test: core-deps script-tests ## Run all tests (needs cargo-nextest; RUSTFS_ALLOW_CARGO_TEST_FALLBACK=1 to override)
|
||||
|
||||
@@ -8,10 +8,26 @@
|
||||
"suite": "e2e_test",
|
||||
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_remote_shards_after_background_target_restart",
|
||||
"oracle": "background-target-restart.json",
|
||||
"evidence": "process-restart",
|
||||
"unclean_shutdown_marker": false,
|
||||
"min_objects": 9,
|
||||
"max_objects": 65,
|
||||
"topology": {"nodes": 4, "drives_per_node": 1},
|
||||
"scope": "Target process restart, exact unversioned S3 bodies and replacement-disk shards; not power loss or EC8+4."
|
||||
},
|
||||
"background-target-crash": {
|
||||
"gate": "G14",
|
||||
"task": "W21",
|
||||
"lane": "e2e-nightly",
|
||||
"suite": "e2e_test",
|
||||
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_remote_shards_after_background_target_crash",
|
||||
"oracle": "background-target-crash.json",
|
||||
"evidence": "process-crash-restart",
|
||||
"unclean_shutdown_marker": true,
|
||||
"min_objects": 9,
|
||||
"max_objects": 65,
|
||||
"topology": {"nodes": 4, "drives_per_node": 1},
|
||||
"scope": "Target process killed during partial background rebuild, real unclean-shutdown marker, exact unversioned S3 bodies and replacement-disk shards; not power loss or EC8+4."
|
||||
}
|
||||
},
|
||||
"release_pending": {
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: overtrue/repo-visuals-action@fd79cba437ecfac933d00a69add17eb95d3939c3 # v1.3.1
|
||||
- uses: overtrue/repo-visuals-action@ee2c632f6ce617e851fb46ea935ee8af762ebb93 # v1.4.0
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
output-branch: star-history
|
||||
|
||||
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Multipart admission queue**: an `UploadPart` waiting for a foreground write permit now waits at most 10 s by default (`RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`, previously 30 s), so a queued part returns S3 `SlowDown` before the client's socket write timeout drops the connection. Separately, the API listener no longer forces a 4 MiB `SO_RCVBUF` on every accepted socket (kernel autotuning applies; `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES` restores a fixed size), so a queued part no longer lets up to 8 MiB of unread body accumulate in kernel memory per connection, which is what throttled whole nodes under SDK-default multipart concurrency. Fixes #7385.
|
||||
- **Helm Ingress**: `customAnnotations` are now merged with class-specific annotations (nginx/traefik) instead of being ignored when `ingress.className` is set.
|
||||
- **Per-pool erasure parity**: Erasure parity (STANDARD and reduced-redundancy) is now resolved independently for every pool instead of reusing the first pool's value. A heterogeneous topology — for example a 4-drive pool plus a 2-drive pool created during expansion — previously inherited the first pool's parity and could resolve to zero data shards in the smaller pool, panicking Reed-Solomon construction on write. Automatic parity now resolves per pool (for example `2+2` in the 4-drive pool and `1+1` in the 2-drive pool). Fixes #4801.
|
||||
|
||||
|
||||
@@ -109,6 +109,15 @@ Star RustFS on GitHub and be instantly notified of new releases.
|
||||
|
||||
## Quickstart
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Pool expansion notice:**
|
||||
>
|
||||
> - A single-node single-drive (SNSD) deployment is supported only as a standalone local path. It cannot expand in place or be added as a Pool. To move to a multi-drive topology, create a new deployment and migrate data through S3.
|
||||
> - Keep an existing multi-drive Pool's endpoints and Erasure Set width unchanged; expand by appending a new Pool. With ellipsis-based expansion, every Pool argument must contain an ellipsis expression and expand to at least two drive endpoints.
|
||||
> - Single-node multi-drive Pools and multi-node Pools with one drive per node are allowed, subject to valid Erasure Set geometry and EC settings; acceptance does not guarantee host-failure tolerance.
|
||||
>
|
||||
> These topology rules follow MinIO, but automatic parity selection differs between the projects. See the [Pool layout compatibility and regression tests](docs/testing/pool-layout-compatibility.md) before expanding a deployment.
|
||||
|
||||
To get started with RustFS, follow these steps:
|
||||
|
||||
### 1. One-click Installation (Option 1)
|
||||
|
||||
@@ -89,6 +89,15 @@ RustFS 是一个基于 Rust 构建的高性能分布式对象存储系统。Rust
|
||||
|
||||
## 快速开始
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Pool 扩容 Notice:**
|
||||
>
|
||||
> - 单节点单盘(SNSD)部署仅支持使用本地路径独立运行,不支持原地扩容,也不能作为 Pool 加入集群。如需改为多盘拓扑,请创建新部署并通过 S3 迁移数据。
|
||||
> - 已有多盘 Pool 的端点和 Erasure Set 宽度应保持不变,扩容应追加新的 Pool。使用省略号表达式扩容时,每个 Pool 参数都必须包含省略号表达式,并展开为至少两个磁盘端点。
|
||||
> - 允许单节点多盘 Pool,也允许多节点、每节点一盘的 Pool,但必须满足 Erasure Set 布局和 EC 配置要求;配置合法不代表能够容忍整台主机故障。
|
||||
>
|
||||
> 这些拓扑规则与 MinIO 一致,但两者的默认 parity 选择方式存在差异。扩容前请阅读 [Pool 布局兼容性与回归测试说明](docs/testing/pool-layout-compatibility.md)。
|
||||
|
||||
请按照以下步骤快速上手 RustFS:
|
||||
|
||||
### 1. 一键安装脚本 (选项 1)
|
||||
|
||||
+16
-1
@@ -66,6 +66,11 @@ Current guidance:
|
||||
|
||||
- `RUSTFS_BROWSER_REDIRECT_URL` sets the externally reachable browser origin used for OIDC callback, console success redirect, and logout fallback URLs. Configure it to the public scheme and authority without a path, for example `https://console.example.com`. In load-balancer deployments, keep OIDC authorize and callback requests on the same backend node because the in-flight OIDC `state` is local to the RustFS node.
|
||||
|
||||
## S3 API environment variables
|
||||
|
||||
- `RUSTFS_API_OBJECT_MAX_VERSIONS` caps the number of retained versions for a single object. It defaults to `9223372036854775807`, matching MinIO's practical-unlimited default. Set a positive integer to enforce a lower per-object metadata bound.
|
||||
- `MINIO_API_OBJECT_MAX_VERSIONS` is accepted as a compatibility alias when the canonical RustFS variable is not set.
|
||||
|
||||
## Distributed endpoint locality
|
||||
|
||||
- `RUSTFS_LOCAL_ENDPOINT_HOST` identifies this server's host in a distributed `RUSTFS_VOLUMES` topology without resolving every peer during startup. Set it to exactly one host, without a scheme, port, or path. It is accepted only for orchestrated URL topologies and must match at least one endpoint on the RustFS server port; invalid or unmatched values fail startup. Leave it unset to retain DNS-based locality discovery.
|
||||
@@ -153,14 +158,24 @@ concurrently. Small direct PUTs stay on the legacy path.
|
||||
- default is `0`.
|
||||
- `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`
|
||||
- how long an `UploadPart` waits in the bounded queue for a permit before returning S3 `SlowDown`; `0` rejects immediately when the pool is full.
|
||||
- default is `30000`. Parts wait before body ingest, so SDK-default clients that send every part of an upload concurrently drain through the pool instead of failing.
|
||||
- default is `10000`. Parts wait before body ingest, so SDK-default clients that send every part of an upload concurrently drain through the pool instead of failing on a full pool.
|
||||
- RustFS does not read the request body while a part is queued, so the client's socket write stalls for the whole wait and whatever timeout the client or an intermediary has configured competes with this value. Keep it with margin below the shortest such timeout in use (botocore applies its 60 s `connect_timeout` to the body write; the AWS SDK for Java v2 has a 30 s socket write timeout; reverse proxies add their own body timeouts); a wait that outlives the client timeout surfaces as a dropped connection instead of `SlowDown`.
|
||||
- `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING`
|
||||
- maximum `UploadPart` requests waiting for a permit at once; parts beyond it return `SlowDown` without waiting.
|
||||
- default is `0`, which derives 16 times the permit limit (512 at stock settings).
|
||||
- each queued HTTP/1 part holds whatever unread body the client already pushed into the connection's kernel receive buffer (an HTTP/2 part holds up to its flow-control window in process memory), so this depth also bounds that memory. RustFS leaves the receive buffer to kernel autotuning (see `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES` below), which keeps an unread connection at the kernel's initial size (128 KiB on current Linux).
|
||||
- `RUSTFS_PUT_FOREGROUND_ADMISSION_ENABLE`, `RUSTFS_PUT_FOREGROUND_ADMISSION_LIMIT`, `RUSTFS_PUT_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`
|
||||
- experimental strict gate that applies to every foreground write regardless of size and replaces the pool above when enabled.
|
||||
- default is disabled; enabling it with limit `0` disables foreground write admission entirely.
|
||||
|
||||
## HTTP listener socket environment variables
|
||||
|
||||
- `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES`
|
||||
- fixed `SO_RCVBUF` for the API listener, inherited by every accepted socket; `0` leaves the receive buffer to kernel autotuning.
|
||||
- default is `0`. Earlier releases hard-coded 4 MiB, which Linux doubles to 8 MiB and which disables autotuning, so every connection whose body was not being read yet (a multipart part queued for a foreground write permit) could accumulate up to 8 MiB of unread body in kernel memory; at SDK-default multipart concurrency that was enough to push a node into TCP memory pressure.
|
||||
- with autotuning the per-connection receive ceiling is the kernel's (`net.ipv4.tcp_rmem` max, 6 MiB on stock Linux) instead of the former fixed 8 MiB, so a single very high-bandwidth-delay connection may see a somewhat lower ceiling; raise `net.ipv4.tcp_rmem` first, and set this variable only on kernels without receive-buffer autotuning (illumos/Solaris) or where the sysctl cannot be changed.
|
||||
- the send buffer stays fixed at 4 MiB because the stock Linux send autotuning ceiling (`net.ipv4.tcp_wmem` max, 4 MiB) is lower than a GB-level response stream needs.
|
||||
|
||||
## Remote tier timeout environment variables
|
||||
|
||||
- `RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS`
|
||||
|
||||
@@ -90,3 +90,15 @@ pub const ENV_API_MAX_CONNECTIONS: &str = "RUSTFS_API_MAX_CONNECTIONS";
|
||||
|
||||
/// Default for `RUSTFS_API_MAX_CONNECTIONS` (`0` = unlimited).
|
||||
pub const DEFAULT_API_MAX_CONNECTIONS: usize = 0;
|
||||
|
||||
/// Maximum retained versions per object.
|
||||
///
|
||||
/// The default follows MinIO and is effectively unlimited for practical
|
||||
/// deployments. Operators can lower it to bound per-object metadata growth.
|
||||
/// Environment variable: RUSTFS_API_OBJECT_MAX_VERSIONS
|
||||
/// MinIO-compatible alias: MINIO_API_OBJECT_MAX_VERSIONS
|
||||
/// Example: RUSTFS_API_OBJECT_MAX_VERSIONS=50000
|
||||
pub const ENV_API_OBJECT_MAX_VERSIONS: &str = "RUSTFS_API_OBJECT_MAX_VERSIONS";
|
||||
|
||||
/// Default for `RUSTFS_API_OBJECT_MAX_VERSIONS`.
|
||||
pub const DEFAULT_API_OBJECT_MAX_VERSIONS: u64 = 9_223_372_036_854_775_807;
|
||||
|
||||
@@ -376,21 +376,39 @@ pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 250;
|
||||
///
|
||||
/// SDK-default multipart clients send every part of an upload concurrently, so
|
||||
/// a single node routinely sees several times more parts in flight than the
|
||||
/// permit pool allows. Those parts have not ingested a body yet, so queueing
|
||||
/// them costs a connection rather than memory or internode streams; the pool
|
||||
/// still bounds the number of parts being written. The wait is long enough for
|
||||
/// an ordinary queue to drain on modest hardware, and a part that cannot get a
|
||||
/// permit within it fails with S3 `SlowDown`/503 for the client to retry.
|
||||
/// `0` rejects immediately when the pool is full.
|
||||
/// permit pool allows. A queued part waits before body ingest, so the pool
|
||||
/// still bounds the number of parts being written, but the wait is not free:
|
||||
/// RustFS does not read the request body while the part is queued (hyper only
|
||||
/// sends `100 Continue` once the body is first polled, and the AWS SDKs send
|
||||
/// the body after a 1-3 s `Expect: 100-continue` grace anyway), so the
|
||||
/// client's socket write stalls once the kernel buffers fill, and whatever
|
||||
/// timeout the client or an intermediary has configured decides the outcome.
|
||||
/// botocore applies its `connect_timeout` (60 s) to the body write, the AWS
|
||||
/// SDK for Java v2 has a 30 s socket write timeout, and MinIO bounds the same
|
||||
/// wait with a 10 s request deadline. The wait must leave margin under the
|
||||
/// shortest of those, not merely fall below an SDK default, so the part
|
||||
/// receives S3 `SlowDown`/503 for the client to retry instead of losing its
|
||||
/// connection (issue #7385). `0` rejects immediately when the pool is full.
|
||||
pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: &str =
|
||||
"RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS";
|
||||
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 30_000;
|
||||
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 10_000;
|
||||
|
||||
// A queued part holds the client's body write open for the whole wait. The
|
||||
// shortest write timeout among mainstream S3 SDKs is the AWS SDK for Java v2's
|
||||
// 30 s socket write timeout; keep the compiled default at no more than a third
|
||||
// of it. This locks only the default; the environment variable may still raise
|
||||
// the wait past any client timeout.
|
||||
const _: () = assert!(DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS * 3 <= 30_000);
|
||||
|
||||
/// Maximum multipart UploadPart requests waiting for a foreground write permit per process.
|
||||
///
|
||||
/// Parts beyond this queue depth are rejected with S3 `SlowDown`/503 without
|
||||
/// waiting, so a genuinely saturated node still fails fast instead of holding
|
||||
/// an unbounded set of connections open for the whole wait timeout.
|
||||
/// an unbounded set of connections open for the whole wait timeout. Each
|
||||
/// queued HTTP/1 part also holds whatever unread body the client already
|
||||
/// pushed into that connection's kernel receive buffer, and a queued HTTP/2
|
||||
/// part holds up to its flow-control window in process memory, so the depth
|
||||
/// bounds socket and window memory as well as connections.
|
||||
/// `0` derives the depth from the permit limit.
|
||||
pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING: &str = "RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING";
|
||||
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING: usize = 0;
|
||||
|
||||
@@ -159,6 +159,24 @@ pub const DEFAULT_HTTP1_HEADER_READ_TIMEOUT: u64 = 75;
|
||||
pub const ENV_HTTP1_MAX_BUF_SIZE: &str = "RUSTFS_HTTP1_MAX_BUF_SIZE";
|
||||
pub const DEFAULT_HTTP1_MAX_BUF_SIZE: usize = 64 * 1024; // 64 KB
|
||||
|
||||
/// Environment variable for a fixed kernel receive buffer (`SO_RCVBUF`, bytes)
|
||||
/// on the API listener. Default: 0, which leaves the buffer to kernel
|
||||
/// autotuning.
|
||||
///
|
||||
/// A fixed `SO_RCVBUF` is inherited by every accepted socket and disables
|
||||
/// receive-buffer autotuning, so a connection whose request body is not being
|
||||
/// read yet (a multipart part queued for a foreground write permit) lets up to
|
||||
/// the fixed size of unread body accumulate in kernel memory — Linux doubles
|
||||
/// the requested value, so the former hard-coded 4 MiB held up to 8 MiB per
|
||||
/// queued connection (issue #7385). Autotuning keeps an unread connection at
|
||||
/// the kernel's initial size and grows only connections that are being
|
||||
/// drained. Set this only on kernels without receive-buffer autotuning
|
||||
/// (illumos/Solaris) or on very high-bandwidth-delay links where the kernel's
|
||||
/// autotuning ceiling (`net.ipv4.tcp_rmem` on Linux) is too low and cannot be
|
||||
/// raised.
|
||||
pub const ENV_HTTP_SOCKET_RECV_BUFFER_BYTES: &str = "RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES";
|
||||
pub const DEFAULT_HTTP_SOCKET_RECV_BUFFER_BYTES: usize = 0;
|
||||
|
||||
/// Environment variable for the S3 request-body inter-chunk read timeout
|
||||
/// (seconds). Default: 300. Set to 0 to disable.
|
||||
///
|
||||
|
||||
@@ -57,6 +57,8 @@ const RUSTFS_FULL_FEATURE: &str = "full";
|
||||
const TEST_PORT_MIN: u16 = 20_000;
|
||||
// Keep allocator ports below the ephemeral range used by bind(..., 0) test helpers.
|
||||
const TEST_PORT_RANGE: u16 = 10_000;
|
||||
const TEST_PORT_MIN_ENV: &str = "RUSTFS_E2E_TEST_PORT_MIN";
|
||||
const TEST_PORT_RANGE_ENV: &str = "RUSTFS_E2E_TEST_PORT_RANGE";
|
||||
const TEST_PORT_COUNTER_PATH: &str = "/tmp/rustfs_e2e_next_port";
|
||||
const TEST_PORT_LOCK_DIR: &str = "/tmp/rustfs_e2e_port_allocator.lock";
|
||||
const TEST_PORT_LOCK_STALE_AFTER: Duration = Duration::from_secs(30);
|
||||
@@ -99,22 +101,74 @@ impl Drop for PortAllocatorGuard {
|
||||
}
|
||||
}
|
||||
|
||||
fn advance_test_port(port: u16) -> u16 {
|
||||
let offset = (port - TEST_PORT_MIN + 1) % TEST_PORT_RANGE;
|
||||
TEST_PORT_MIN + offset
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct TestPortAllocatorConfig {
|
||||
min: u16,
|
||||
range: u16,
|
||||
}
|
||||
|
||||
fn seeded_test_port() -> u16 {
|
||||
let offset = (Uuid::new_v4().as_u128() % u128::from(TEST_PORT_RANGE)) as u16;
|
||||
TEST_PORT_MIN + offset
|
||||
impl TestPortAllocatorConfig {
|
||||
fn max_exclusive(self) -> u32 {
|
||||
u32::from(self.min) + u32::from(self.range)
|
||||
}
|
||||
|
||||
fn contains(self, port: &u16) -> bool {
|
||||
(u32::from(self.min)..self.max_exclusive()).contains(&u32::from(*port))
|
||||
}
|
||||
}
|
||||
|
||||
fn read_next_test_port() -> u16 {
|
||||
fn parse_test_port_allocator_config(
|
||||
min_override: Option<&str>,
|
||||
range_override: Option<&str>,
|
||||
) -> Result<TestPortAllocatorConfig, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let min = match min_override {
|
||||
Some(value) => value
|
||||
.parse::<u16>()
|
||||
.map_err(|err| format!("{TEST_PORT_MIN_ENV} must be a valid u16: {err}"))?,
|
||||
None => TEST_PORT_MIN,
|
||||
};
|
||||
let range = match range_override {
|
||||
Some(value) => value
|
||||
.parse::<u16>()
|
||||
.map_err(|err| format!("{TEST_PORT_RANGE_ENV} must be a valid u16: {err}"))?,
|
||||
None => TEST_PORT_RANGE,
|
||||
};
|
||||
if range == 0 {
|
||||
return Err(format!("{TEST_PORT_RANGE_ENV} must be greater than zero").into());
|
||||
}
|
||||
if min < 1024 {
|
||||
return Err(format!("{TEST_PORT_MIN_ENV} must be at least 1024").into());
|
||||
}
|
||||
let max_exclusive = u32::from(min) + u32::from(range);
|
||||
if max_exclusive > u32::from(u16::MAX) + 1 {
|
||||
return Err(format!("{TEST_PORT_MIN_ENV} + {TEST_PORT_RANGE_ENV} exceeds u16 port space").into());
|
||||
}
|
||||
Ok(TestPortAllocatorConfig { min, range })
|
||||
}
|
||||
|
||||
fn test_port_allocator_config() -> Result<TestPortAllocatorConfig, Box<dyn std::error::Error + Send + Sync>> {
|
||||
parse_test_port_allocator_config(
|
||||
std::env::var(TEST_PORT_MIN_ENV).ok().as_deref(),
|
||||
std::env::var(TEST_PORT_RANGE_ENV).ok().as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn advance_test_port(port: u16, config: TestPortAllocatorConfig) -> u16 {
|
||||
let offset = (port - config.min + 1) % config.range;
|
||||
config.min + offset
|
||||
}
|
||||
|
||||
fn seeded_test_port(config: TestPortAllocatorConfig) -> u16 {
|
||||
let offset = (Uuid::new_v4().as_u128() % u128::from(config.range)) as u16;
|
||||
config.min + offset
|
||||
}
|
||||
|
||||
fn read_next_test_port(config: TestPortAllocatorConfig) -> u16 {
|
||||
stdfs::read_to_string(TEST_PORT_COUNTER_PATH)
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse::<u16>().ok())
|
||||
.filter(|port| (TEST_PORT_MIN..TEST_PORT_MIN + TEST_PORT_RANGE).contains(port))
|
||||
.unwrap_or_else(seeded_test_port)
|
||||
.filter(|port| config.contains(port))
|
||||
.unwrap_or_else(|| seeded_test_port(config))
|
||||
}
|
||||
|
||||
fn remove_stale_port_allocator_lock() {
|
||||
@@ -629,11 +683,12 @@ impl RustFSTestEnvironment {
|
||||
pub async fn find_available_port() -> Result<u16, Box<dyn std::error::Error + Send + Sync>> {
|
||||
use std::net::TcpListener;
|
||||
let _guard = PortAllocatorGuard::acquire().await?;
|
||||
let mut next_port = read_next_test_port();
|
||||
let config = test_port_allocator_config()?;
|
||||
let mut next_port = read_next_test_port(config);
|
||||
|
||||
for _ in 0..TEST_PORT_RANGE {
|
||||
for _ in 0..config.range {
|
||||
let port = next_port;
|
||||
next_port = advance_test_port(next_port);
|
||||
next_port = advance_test_port(next_port, config);
|
||||
write_next_test_port(next_port)?;
|
||||
|
||||
if let Ok(listener) = TcpListener::bind(("127.0.0.1", port)) {
|
||||
@@ -2108,6 +2163,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_port_allocator_uses_default_range() {
|
||||
assert_eq!(
|
||||
parse_test_port_allocator_config(None, None).expect("default port allocator config"),
|
||||
TestPortAllocatorConfig {
|
||||
min: TEST_PORT_MIN,
|
||||
range: TEST_PORT_RANGE
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_port_allocator_accepts_explicit_test_range() {
|
||||
let config = parse_test_port_allocator_config(Some("31000"), Some("128")).expect("explicit port range");
|
||||
|
||||
assert_eq!(advance_test_port(31127, config), 31000);
|
||||
assert!(config.contains(&31000));
|
||||
assert!(config.contains(&31127));
|
||||
assert!(!config.contains(&31128));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_port_allocator_rejects_invalid_override() {
|
||||
assert!(parse_test_port_allocator_config(Some("1023"), Some("1")).is_err());
|
||||
assert!(parse_test_port_allocator_config(Some("65000"), Some("1000")).is_err());
|
||||
assert!(parse_test_port_allocator_config(Some("31000"), Some("0")).is_err());
|
||||
assert!(parse_test_port_allocator_config(Some("not-a-port"), Some("128")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_rustfs_binary_in_configured_cargo_target_directory() {
|
||||
let workspace = Path::new("workspace");
|
||||
|
||||
@@ -34,12 +34,14 @@ use s3s::dto::{
|
||||
AbortMultipartUploadInput, AbortMultipartUploadOutput, CommonPrefix, CompleteMultipartUploadInput,
|
||||
CompleteMultipartUploadOutput, CreateMultipartUploadInput, CreateMultipartUploadOutput, DeleteMarkerEntry, DeleteObjectInput,
|
||||
DeleteObjectOutput, DeleteObjectTaggingInput, DeleteObjectTaggingOutput, ETag, GetBucketVersioningInput,
|
||||
GetBucketVersioningOutput, GetObjectInput, GetObjectLockConfigurationInput, GetObjectLockConfigurationOutput,
|
||||
GetObjectOutput, GetObjectTaggingInput, GetObjectTaggingOutput, HeadBucketInput, HeadBucketOutput, HeadObjectInput,
|
||||
GetBucketVersioningOutput, GetObjectInput, GetObjectLegalHoldInput, GetObjectLegalHoldOutput,
|
||||
GetObjectLockConfigurationInput, GetObjectLockConfigurationOutput, GetObjectOutput, GetObjectRetentionInput,
|
||||
GetObjectRetentionOutput, GetObjectTaggingInput, GetObjectTaggingOutput, HeadBucketInput, HeadBucketOutput, HeadObjectInput,
|
||||
HeadObjectOutput, ListObjectVersionsInput, ListObjectVersionsOutput, ListObjectsV2Input, ListObjectsV2Output, Object,
|
||||
ObjectLockConfiguration, ObjectLockEnabled, ObjectStorageClass, ObjectVersionId, PutObjectInput, PutObjectOutput,
|
||||
PutObjectTaggingInput, PutObjectTaggingOutput, Range, StreamingBlob, Tag, TagSet, Timestamp, TimestampFormat,
|
||||
UploadPartInput, UploadPartOutput,
|
||||
ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockMode,
|
||||
ObjectLockRetention, ObjectLockRetentionMode, ObjectStorageClass, ObjectVersionId, PutObjectInput, PutObjectLegalHoldInput,
|
||||
PutObjectLegalHoldOutput, PutObjectOutput, PutObjectRetentionInput, PutObjectRetentionOutput, PutObjectTaggingInput,
|
||||
PutObjectTaggingOutput, Range, StreamingBlob, Tag, TagSet, Timestamp, TimestampFormat, UploadPartInput, UploadPartOutput,
|
||||
};
|
||||
use s3s::service::{S3Service, S3ServiceBuilder};
|
||||
use s3s::validation::{AwsNameValidation, NameValidation};
|
||||
@@ -127,6 +129,10 @@ pub enum Operation {
|
||||
GetObjectTagging,
|
||||
PutObjectTagging,
|
||||
DeleteObjectTagging,
|
||||
GetObjectRetention,
|
||||
PutObjectRetention,
|
||||
GetObjectLegalHold,
|
||||
PutObjectLegalHold,
|
||||
ListObjectVersions,
|
||||
ListObjectsV2,
|
||||
CreateMultipartUpload,
|
||||
@@ -501,6 +507,10 @@ struct StoreState {
|
||||
/// PutObject carrying any `x-amz-object-lock-*` header must also carry
|
||||
/// `Content-MD5` or an `x-amz-checksum-*` header.
|
||||
require_checksum_for_object_lock: bool,
|
||||
/// Models Wasabi (rustfs/backlog#2340): a version-addressed DELETE of a
|
||||
/// version id the target never had answers 404 `NoSuchVersion` instead of
|
||||
/// the idempotent 204 RustFS/MinIO give.
|
||||
reject_unknown_version_deletes: bool,
|
||||
limits: StoreLimits,
|
||||
buckets: HashMap<String, BucketState>,
|
||||
uploads: HashMap<String, MultipartState>,
|
||||
@@ -565,6 +575,41 @@ struct ObjectVersion {
|
||||
/// SSE-C passthrough transport headers stored with the version (RustFS
|
||||
/// target behavior); empty when the drop mode discarded them.
|
||||
replication_sse_headers: Vec<(String, String)>,
|
||||
/// Object Lock state of the version: retention (mode, retain-until) from
|
||||
/// the PUT / CreateMultipartUpload headers or PutObjectRetention, and the
|
||||
/// legal hold flag; replayed on HEAD.
|
||||
lock: VersionLock,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct VersionLock {
|
||||
retention: Option<(String, Timestamp)>,
|
||||
/// `None` until a legal hold status was ever set; like S3, HEAD then
|
||||
/// reports nothing, while an explicit OFF is reported as `OFF`.
|
||||
legal_hold: Option<bool>,
|
||||
}
|
||||
|
||||
impl VersionLock {
|
||||
fn from_headers(
|
||||
mode: Option<ObjectLockMode>,
|
||||
retain_until: Option<Timestamp>,
|
||||
legal_hold: Option<ObjectLockLegalHoldStatus>,
|
||||
) -> Self {
|
||||
Self {
|
||||
retention: mode.zip(retain_until).map(|(mode, until)| (mode.as_str().to_string(), until)),
|
||||
legal_hold: legal_hold.map(|status| status.as_str().eq_ignore_ascii_case("ON")),
|
||||
}
|
||||
}
|
||||
|
||||
fn legal_hold_status(&self) -> Option<ObjectLockLegalHoldStatus> {
|
||||
self.legal_hold.map(|on| {
|
||||
ObjectLockLegalHoldStatus::from_static(if on {
|
||||
ObjectLockLegalHoldStatus::ON
|
||||
} else {
|
||||
ObjectLockLegalHoldStatus::OFF
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -576,6 +621,7 @@ struct MultipartState {
|
||||
metadata: Option<HashMap<String, String>>,
|
||||
standard_headers: StandardHeaders,
|
||||
replication_sse_headers: Vec<(String, String)>,
|
||||
lock: VersionLock,
|
||||
parts: BTreeMap<i32, MultipartPart>,
|
||||
}
|
||||
|
||||
@@ -584,6 +630,10 @@ struct MultipartPart {
|
||||
body: Bytes,
|
||||
e_tag: String,
|
||||
digest: [u8; 16],
|
||||
/// Plaintext length declared by an SSE-C passthrough sender
|
||||
/// (`x-rustfs-replication-part-actual-size`); RustFS validates the 5 MiB
|
||||
/// minimum against it rather than against the stored bytes.
|
||||
actual_size: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -845,6 +895,7 @@ impl FakeS3Target {
|
||||
standard_headers: seed.standard_headers.clone(),
|
||||
tags: Vec::new(),
|
||||
replication_sse_headers: Vec::new(),
|
||||
lock: VersionLock::default(),
|
||||
};
|
||||
upsert_version(&mut state, bucket, key.into(), version).expect("seed object must fit the storage budget");
|
||||
e_tag
|
||||
@@ -918,6 +969,12 @@ impl FakeS3Target {
|
||||
/// PutObject that carries Object Lock parameters (AWS S3 / MinIO rule,
|
||||
/// rustfs#7082). `Content-MD5`, when present, is always verified against
|
||||
/// the body regardless of this mode.
|
||||
/// Wasabi-like mode: DELETE of an unknown version id answers 404
|
||||
/// `NoSuchVersion` (the default 204 models RustFS/MinIO).
|
||||
pub fn reject_unknown_version_deletes(&self, enabled: bool) {
|
||||
lock(&self.backend.store).reject_unknown_version_deletes = enabled;
|
||||
}
|
||||
|
||||
pub fn require_checksum_for_object_lock(&self, enabled: bool) {
|
||||
lock(&self.backend.store).require_checksum_for_object_lock = enabled;
|
||||
}
|
||||
@@ -1152,6 +1209,10 @@ fn operation_from_s3_name(name: &str) -> Operation {
|
||||
"GetObjectTagging" => Operation::GetObjectTagging,
|
||||
"PutObjectTagging" => Operation::PutObjectTagging,
|
||||
"DeleteObjectTagging" => Operation::DeleteObjectTagging,
|
||||
"GetObjectRetention" => Operation::GetObjectRetention,
|
||||
"PutObjectRetention" => Operation::PutObjectRetention,
|
||||
"GetObjectLegalHold" => Operation::GetObjectLegalHold,
|
||||
"PutObjectLegalHold" => Operation::PutObjectLegalHold,
|
||||
"ListObjectsV2" => Operation::ListObjectsV2,
|
||||
"CreateMultipartUpload" => Operation::CreateMultipartUpload,
|
||||
"UploadPart" => Operation::UploadPart,
|
||||
@@ -1289,6 +1350,18 @@ fn parse_request(method: &Method, uri: &Uri) -> ParsedRequest {
|
||||
(&Method::DELETE, true) if query.contains_key("tagging") && only_query_keys(&["tagging", "versionId"]) => {
|
||||
Operation::DeleteObjectTagging
|
||||
}
|
||||
(&Method::GET, true) if query.contains_key("retention") && only_query_keys(&["retention", "versionId"]) => {
|
||||
Operation::GetObjectRetention
|
||||
}
|
||||
(&Method::PUT, true) if query.contains_key("retention") && only_query_keys(&["retention", "versionId"]) => {
|
||||
Operation::PutObjectRetention
|
||||
}
|
||||
(&Method::GET, true) if query.contains_key("legal-hold") && only_query_keys(&["legal-hold", "versionId"]) => {
|
||||
Operation::GetObjectLegalHold
|
||||
}
|
||||
(&Method::PUT, true) if query.contains_key("legal-hold") && only_query_keys(&["legal-hold", "versionId"]) => {
|
||||
Operation::PutObjectLegalHold
|
||||
}
|
||||
// A replication PUT addresses the source version via `?versionId=`.
|
||||
(&Method::PUT, true) if only_query_keys(&["versionId"]) => Operation::PutObject,
|
||||
(&Method::GET, true) if only_query_keys(&["versionId"]) => Operation::GetObject,
|
||||
@@ -1842,6 +1915,28 @@ fn set_version_tags(
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
fn update_version_lock(
|
||||
state: &mut StoreState,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
version_id: Option<&str>,
|
||||
update: impl FnOnce(&mut VersionLock),
|
||||
) -> S3Result<String> {
|
||||
let resolved = find_version(state, bucket, key, version_id)?.version_id;
|
||||
let version = state
|
||||
.buckets
|
||||
.get_mut(bucket)
|
||||
.expect("bucket existence checked by find_version")
|
||||
.objects
|
||||
.get_mut(key)
|
||||
.expect("key existence checked by find_version")
|
||||
.iter_mut()
|
||||
.find(|version| version.version_id == resolved)
|
||||
.expect("version existence checked by find_version");
|
||||
update(&mut version.lock);
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
/// Whether version ids are surfaced for this bucket. Unknown buckets report
|
||||
/// `true`; the caller's lookup raises `NoSuchBucket` first.
|
||||
fn bucket_versioned(state: &StoreState, bucket: &str) -> bool {
|
||||
@@ -2281,6 +2376,11 @@ impl S3 for FakeBackend {
|
||||
standard_headers,
|
||||
tags: Vec::new(),
|
||||
replication_sse_headers: captured_replication_sse_headers(&headers, drop_unlisted),
|
||||
lock: VersionLock::from_headers(
|
||||
input.object_lock_mode,
|
||||
input.object_lock_retain_until_date,
|
||||
input.object_lock_legal_hold_status,
|
||||
),
|
||||
};
|
||||
upsert_version(&mut lock(&self.store), &input.bucket, input.key, version)?;
|
||||
Ok(apply_response_fault(
|
||||
@@ -2339,6 +2439,13 @@ impl S3 for FakeBackend {
|
||||
last_modified: Some(version.last_modified.clone()),
|
||||
version_id: versioned.then_some(version.version_id),
|
||||
sse_customer_algorithm,
|
||||
object_lock_mode: version
|
||||
.lock
|
||||
.retention
|
||||
.as_ref()
|
||||
.map(|(mode, _)| ObjectLockMode::from(mode.clone())),
|
||||
object_lock_retain_until_date: version.lock.retention.as_ref().map(|(_, until)| until.clone()),
|
||||
object_lock_legal_hold_status: version.lock.legal_hold_status(),
|
||||
..Default::default()
|
||||
});
|
||||
response.status = served.status;
|
||||
@@ -2373,6 +2480,13 @@ impl S3 for FakeBackend {
|
||||
last_modified: Some(version.last_modified.clone()),
|
||||
version_id: versioned.then_some(version.version_id),
|
||||
sse_customer_algorithm,
|
||||
object_lock_mode: version
|
||||
.lock
|
||||
.retention
|
||||
.as_ref()
|
||||
.map(|(mode, _)| ObjectLockMode::from(mode.clone())),
|
||||
object_lock_retain_until_date: version.lock.retention.as_ref().map(|(_, until)| until.clone()),
|
||||
object_lock_legal_hold_status: version.lock.legal_hold_status(),
|
||||
..Default::default()
|
||||
});
|
||||
response.status = served.status;
|
||||
@@ -2432,6 +2546,82 @@ impl S3 for FakeBackend {
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_object_retention(
|
||||
&self,
|
||||
req: S3Request<GetObjectRetentionInput>,
|
||||
) -> S3Result<S3Response<GetObjectRetentionOutput>> {
|
||||
let fault = request_fault(&req);
|
||||
apply_non_body_fault(fault.as_ref(), &self.control).await?;
|
||||
let input = req.input;
|
||||
let version = find_version(&lock(&self.store), &input.bucket, &input.key, input.version_id.as_deref())?;
|
||||
Ok(apply_response_fault(
|
||||
S3Response::new(GetObjectRetentionOutput {
|
||||
retention: version.lock.retention.map(|(mode, until)| ObjectLockRetention {
|
||||
mode: Some(ObjectLockRetentionMode::from(mode)),
|
||||
retain_until_date: Some(until),
|
||||
}),
|
||||
}),
|
||||
fault.as_ref(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn put_object_retention(
|
||||
&self,
|
||||
req: S3Request<PutObjectRetentionInput>,
|
||||
) -> S3Result<S3Response<PutObjectRetentionOutput>> {
|
||||
let fault = request_fault(&req);
|
||||
apply_non_body_fault(fault.as_ref(), &self.control).await?;
|
||||
let input = req.input;
|
||||
let retention = input
|
||||
.retention
|
||||
.and_then(|retention| retention.mode.zip(retention.retain_until_date))
|
||||
.map(|(mode, until)| (mode.as_str().to_string(), until));
|
||||
update_version_lock(&mut lock(&self.store), &input.bucket, &input.key, input.version_id.as_deref(), |lock| {
|
||||
lock.retention = retention;
|
||||
})?;
|
||||
Ok(apply_response_fault(S3Response::new(PutObjectRetentionOutput::default()), fault.as_ref()))
|
||||
}
|
||||
|
||||
async fn get_object_legal_hold(
|
||||
&self,
|
||||
req: S3Request<GetObjectLegalHoldInput>,
|
||||
) -> S3Result<S3Response<GetObjectLegalHoldOutput>> {
|
||||
let fault = request_fault(&req);
|
||||
apply_non_body_fault(fault.as_ref(), &self.control).await?;
|
||||
let input = req.input;
|
||||
let version = find_version(&lock(&self.store), &input.bucket, &input.key, input.version_id.as_deref())?;
|
||||
Ok(apply_response_fault(
|
||||
S3Response::new(GetObjectLegalHoldOutput {
|
||||
legal_hold: Some(ObjectLockLegalHold {
|
||||
status: Some(
|
||||
version
|
||||
.lock
|
||||
.legal_hold_status()
|
||||
.unwrap_or_else(|| ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::OFF)),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
fault.as_ref(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn put_object_legal_hold(
|
||||
&self,
|
||||
req: S3Request<PutObjectLegalHoldInput>,
|
||||
) -> S3Result<S3Response<PutObjectLegalHoldOutput>> {
|
||||
let fault = request_fault(&req);
|
||||
apply_non_body_fault(fault.as_ref(), &self.control).await?;
|
||||
let input = req.input;
|
||||
let legal_hold_on = input
|
||||
.legal_hold
|
||||
.and_then(|hold| hold.status)
|
||||
.is_some_and(|status| status.as_str().eq_ignore_ascii_case("ON"));
|
||||
update_version_lock(&mut lock(&self.store), &input.bucket, &input.key, input.version_id.as_deref(), |lock| {
|
||||
lock.legal_hold = Some(legal_hold_on);
|
||||
})?;
|
||||
Ok(apply_response_fault(S3Response::new(PutObjectLegalHoldOutput::default()), fault.as_ref()))
|
||||
}
|
||||
|
||||
async fn delete_object_tagging(
|
||||
&self,
|
||||
req: S3Request<DeleteObjectTaggingInput>,
|
||||
@@ -2485,6 +2675,7 @@ impl S3 for FakeBackend {
|
||||
return Ok(apply_response_fault(S3Response::new(DeleteObjectOutput::default()), fault.as_ref()));
|
||||
}
|
||||
if let Some(version_id) = input.version_id {
|
||||
let reject_unknown = state.reject_unknown_version_deletes;
|
||||
let (removed_bytes, removed_versions, delete_marker, remove_key) = {
|
||||
let Some(versions) = state
|
||||
.buckets
|
||||
@@ -2493,6 +2684,9 @@ impl S3 for FakeBackend {
|
||||
.objects
|
||||
.get_mut(&input.key)
|
||||
else {
|
||||
if reject_unknown {
|
||||
return Err(s3s::s3_error!(NoSuchVersion, "The specified version does not exist."));
|
||||
}
|
||||
return Ok(apply_response_fault(
|
||||
S3Response::new(DeleteObjectOutput {
|
||||
version_id: Some(version_id),
|
||||
@@ -2501,6 +2695,9 @@ impl S3 for FakeBackend {
|
||||
fault.as_ref(),
|
||||
));
|
||||
};
|
||||
if reject_unknown && !versions.iter().any(|version| version.version_id == version_id) {
|
||||
return Err(s3s::s3_error!(NoSuchVersion, "The specified version does not exist."));
|
||||
}
|
||||
let mut removed_bytes = 0usize;
|
||||
let mut removed_versions = 0usize;
|
||||
let mut delete_marker = None;
|
||||
@@ -2554,6 +2751,7 @@ impl S3 for FakeBackend {
|
||||
standard_headers: StandardHeaders::default(),
|
||||
tags: Vec::new(),
|
||||
replication_sse_headers: Vec::new(),
|
||||
lock: VersionLock::default(),
|
||||
},
|
||||
)?;
|
||||
Ok(apply_response_fault(
|
||||
@@ -2608,6 +2806,11 @@ impl S3 for FakeBackend {
|
||||
metadata: input.metadata,
|
||||
standard_headers,
|
||||
replication_sse_headers: captured_replication_sse_headers(&headers, drop_unlisted),
|
||||
lock: VersionLock::from_headers(
|
||||
input.object_lock_mode,
|
||||
input.object_lock_retain_until_date,
|
||||
input.object_lock_legal_hold_status,
|
||||
),
|
||||
parts: BTreeMap::new(),
|
||||
},
|
||||
);
|
||||
@@ -2624,6 +2827,11 @@ impl S3 for FakeBackend {
|
||||
|
||||
async fn upload_part(&self, req: S3Request<UploadPartInput>) -> S3Result<S3Response<UploadPartOutput>> {
|
||||
let fault = request_fault(&req);
|
||||
let declared_actual_size = req
|
||||
.headers
|
||||
.get("x-rustfs-replication-part-actual-size")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse::<usize>().ok());
|
||||
let _body_permit = timeout(MAX_FAULT_DURATION, Arc::clone(&self.body_limit).acquire_owned())
|
||||
.await
|
||||
.map_err(|_| s3s::s3_error!(RequestTimeout, "fake target body limiter wait exceeded 30 seconds"))?
|
||||
@@ -2665,6 +2873,7 @@ impl S3 for FakeBackend {
|
||||
body,
|
||||
e_tag: e_tag.clone(),
|
||||
digest,
|
||||
actual_size: declared_actual_size,
|
||||
},
|
||||
);
|
||||
Ok(apply_response_fault(
|
||||
@@ -2734,7 +2943,9 @@ impl S3 for FakeBackend {
|
||||
if requested_etag != &stored.e_tag {
|
||||
return Err(s3s::s3_error!(InvalidPart, "part ETag does not match"));
|
||||
}
|
||||
if index + 1 != requested_parts.len() && stored.body.len() < MIN_MULTIPART_PART_BYTES {
|
||||
if index + 1 != requested_parts.len()
|
||||
&& stored.actual_size.unwrap_or(stored.body.len()) < MIN_MULTIPART_PART_BYTES
|
||||
{
|
||||
return Err(s3s::s3_error!(EntityTooSmall, "non-final multipart part is smaller than 5 MiB"));
|
||||
}
|
||||
selected.push((*number, stored.clone()));
|
||||
@@ -2748,6 +2959,7 @@ impl S3 for FakeBackend {
|
||||
metadata: upload.metadata.clone(),
|
||||
standard_headers: upload.standard_headers.clone(),
|
||||
replication_sse_headers: upload.replication_sse_headers.clone(),
|
||||
lock: upload.lock.clone(),
|
||||
parts: BTreeMap::new(),
|
||||
},
|
||||
selected,
|
||||
@@ -2778,6 +2990,7 @@ impl S3 for FakeBackend {
|
||||
standard_headers: upload.standard_headers,
|
||||
tags: Vec::new(),
|
||||
replication_sse_headers: upload.replication_sse_headers,
|
||||
lock: upload.lock,
|
||||
};
|
||||
let mut state = lock(&self.store);
|
||||
let versioned = bucket_versioned(&state, &input.bucket);
|
||||
@@ -4609,6 +4822,7 @@ mod tests {
|
||||
metadata: None,
|
||||
standard_headers: StandardHeaders::default(),
|
||||
replication_sse_headers: Vec::new(),
|
||||
lock: VersionLock::default(),
|
||||
parts: BTreeMap::new(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -58,11 +58,22 @@ mod tests {
|
||||
struct ScannerHealEvidenceCase {
|
||||
id: &'static str,
|
||||
oracle: &'static str,
|
||||
evidence: &'static str,
|
||||
unclean_shutdown_marker: bool,
|
||||
}
|
||||
|
||||
const BACKGROUND_TARGET_RESTART_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
||||
id: "background-target-restart",
|
||||
oracle: "background-target-restart.json",
|
||||
evidence: "process-restart",
|
||||
unclean_shutdown_marker: false,
|
||||
};
|
||||
|
||||
const BACKGROUND_TARGET_CRASH_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
||||
id: "background-target-crash",
|
||||
oracle: "background-target-crash.json",
|
||||
evidence: "process-crash-restart",
|
||||
unclean_shutdown_marker: true,
|
||||
};
|
||||
|
||||
struct RestartEvidenceContext {
|
||||
@@ -98,6 +109,8 @@ mod tests {
|
||||
|| case.oracle.contains('/')
|
||||
|| case.oracle.contains('\\')
|
||||
|| case.oracle.contains("..")
|
||||
|| !matches!(case.evidence, "process-restart" | "process-crash-restart")
|
||||
|| (case.evidence == "process-crash-restart") != case.unclean_shutdown_marker
|
||||
{
|
||||
return Err("invalid scanner/heal evidence case".into());
|
||||
}
|
||||
@@ -950,6 +963,16 @@ mod tests {
|
||||
.await?
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_crash()
|
||||
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
timeout(
|
||||
Duration::from_secs(420),
|
||||
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrash),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_cluster_root_heal_recovers_remote_shards_after_coordinator_restart() -> Result<(), Box<dyn Error + Send + Sync>>
|
||||
{
|
||||
@@ -986,21 +1009,27 @@ mod tests {
|
||||
enum InterruptionScenario {
|
||||
IsolatedTargetRestart,
|
||||
BackgroundTargetRestart,
|
||||
BackgroundTargetCrash,
|
||||
BackgroundCoordinatorRestart,
|
||||
TargetEndpointBlackhole,
|
||||
}
|
||||
|
||||
async fn run_cluster_root_heal_interruption(scenario: InterruptionScenario) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let server_binary = rustfs_binary_path();
|
||||
let evidence_run = if scenario == InterruptionScenario::BackgroundTargetRestart {
|
||||
restart_evidence_run(&server_binary, BACKGROUND_TARGET_RESTART_EVIDENCE)?
|
||||
} else {
|
||||
None
|
||||
let evidence_run = match scenario {
|
||||
InterruptionScenario::BackgroundTargetRestart => {
|
||||
restart_evidence_run(&server_binary, BACKGROUND_TARGET_RESTART_EVIDENCE)?
|
||||
}
|
||||
InterruptionScenario::BackgroundTargetCrash => {
|
||||
restart_evidence_run(&server_binary, BACKGROUND_TARGET_CRASH_EVIDENCE)?
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let mut evidence_objects = Vec::new();
|
||||
let (background_enabled, interruption_node, interruption_kind) = match scenario {
|
||||
InterruptionScenario::IsolatedTargetRestart => (false, 1, "target_restart"),
|
||||
InterruptionScenario::BackgroundTargetRestart => (true, 1, "background_target_restart"),
|
||||
InterruptionScenario::BackgroundTargetCrash => (true, 1, "background_target_crash"),
|
||||
InterruptionScenario::BackgroundCoordinatorRestart => (true, 0, "coordinator_restart"),
|
||||
InterruptionScenario::TargetEndpointBlackhole => (false, 1, "target_endpoint_blackhole"),
|
||||
};
|
||||
@@ -1067,6 +1096,7 @@ mod tests {
|
||||
.unwrap_or(4 * 1024 * 1024)
|
||||
.clamp(1024 * 1024, 16 * 1024 * 1024);
|
||||
let mut expected_manifests = Vec::with_capacity(online_object_count);
|
||||
let mut unclean_shutdown_marker_observed = None;
|
||||
for index in 0..online_object_count {
|
||||
let key = format!("cluster/online/object-{index:04}.bin");
|
||||
let payload_seed = u8::try_from(index + 1).expect("clamped object count must fit in u8");
|
||||
@@ -1433,7 +1463,11 @@ mod tests {
|
||||
"Restored target endpoint forwarding"
|
||||
);
|
||||
} else {
|
||||
cluster.stop_node(interruption_node)?;
|
||||
if scenario == InterruptionScenario::BackgroundTargetRestart {
|
||||
cluster.stop_node_gracefully(interruption_node).await?;
|
||||
} else {
|
||||
cluster.stop_node(interruption_node)?;
|
||||
}
|
||||
let stopped_count = metadata_count(&replaced_disk, bucket, &expected_manifests);
|
||||
assert!(
|
||||
stopped_count > 0 && stopped_count < expected_manifests.len(),
|
||||
@@ -1449,9 +1483,12 @@ mod tests {
|
||||
.join(".rustfs.sys")
|
||||
.join("unclean-shutdown");
|
||||
if background_enabled {
|
||||
let marker_exists = unclean_shutdown_marker.is_file();
|
||||
unclean_shutdown_marker_observed = Some(marker_exists);
|
||||
let expected_marker = !matches!(scenario, InterruptionScenario::BackgroundTargetRestart);
|
||||
assert!(
|
||||
unclean_shutdown_marker.is_file(),
|
||||
"background restart must retain the real unclean-shutdown marker"
|
||||
marker_exists == expected_marker,
|
||||
"background restart/crash lane observed unexpected unclean-shutdown marker state"
|
||||
);
|
||||
} else {
|
||||
match std::fs::remove_file(&unclean_shutdown_marker) {
|
||||
@@ -1651,13 +1688,14 @@ mod tests {
|
||||
"server build changed during restart"
|
||||
);
|
||||
let evidence = serde_json::json!({
|
||||
"schema": 1, "case": evidence_context.case.id, "evidence": "process-restart",
|
||||
"schema": 1, "case": evidence_context.case.id, "evidence": evidence_context.case.evidence,
|
||||
"run_id": evidence_context.run.run_id, "source_revision": evidence_context.run.source_revision,
|
||||
"test_build": compiled_test_identity(),
|
||||
"binary_sha256": evidence_context.run.binary.sha256,
|
||||
"test_binary_sha256": evidence_context.run.test_binary.sha256,
|
||||
"topology": {"nodes": cluster.nodes.len(), "drives_per_node": cluster.nodes[0].data_dirs.len()},
|
||||
"pid_before": target_pid, "pid_after": restarted_pid,
|
||||
"unclean_shutdown_marker": unclean_shutdown_marker_observed.unwrap_or(false),
|
||||
"objects": evidence_objects, "node_listings": node_listings,
|
||||
});
|
||||
let data = serde_json::to_vec(&evidence)?;
|
||||
|
||||
@@ -512,7 +512,7 @@ pub(crate) async fn put_bucket_replication(
|
||||
put_bucket_replication_with_delete_statuses(env, bucket, target_arn, "Enabled", None).await
|
||||
}
|
||||
|
||||
async fn put_bucket_replication_with_delete_statuses(
|
||||
pub(crate) async fn put_bucket_replication_with_delete_statuses(
|
||||
env: &RustFSTestEnvironment,
|
||||
bucket: &str,
|
||||
target_arn: &str,
|
||||
@@ -627,7 +627,7 @@ async fn put_bucket_replication_rules(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_bucket_replication(
|
||||
pub(crate) async fn delete_bucket_replication(
|
||||
env: &RustFSTestEnvironment,
|
||||
bucket: &str,
|
||||
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
|
||||
@@ -9055,9 +9055,11 @@ async fn test_replication_check_flags_multipart_only_version_minting_target() ->
|
||||
.is_some_and(|error| error.contains("CreateMultipartUpload")),
|
||||
"the failure must name the multipart path: {payload}"
|
||||
);
|
||||
// The PutObject leg mirrored, so it is the multipart probe that failed.
|
||||
// The PutObject leg mirrored, so it is the multipart probe that failed;
|
||||
// the mutation phases address the id the PUT reported and still run.
|
||||
assert_eq!(target_report["Phases"]["Put"]["Status"], "OK", "{payload}");
|
||||
assert_eq!(target_report["Phases"]["DeleteMarker"]["Status"], "SKIPPED", "{payload}");
|
||||
assert_eq!(target_report["Phases"]["DeleteMarker"]["Status"], "OK", "{payload}");
|
||||
assert_eq!(target_report["Phases"]["VersionDelete"]["Status"], "OK", "{payload}");
|
||||
assert_eq!(target_report["Phases"]["Cleanup"]["Status"], "OK", "{payload}");
|
||||
|
||||
let probe_key = target
|
||||
@@ -9245,6 +9247,9 @@ async fn test_replication_check_flags_version_minting_target() -> TestResult {
|
||||
let target_bucket = "version-fidelity-dst";
|
||||
target.create_bucket(target_bucket);
|
||||
target.assign_own_version_ids(true);
|
||||
// Wasabi shape: the probe version the VersionDelete phase removed answers
|
||||
// NoSuchVersion to cleanup's second DELETE, which must count as clean.
|
||||
target.reject_unknown_version_deletes(true);
|
||||
|
||||
let mut source_env = RustFSTestEnvironment::new().await?;
|
||||
let mut env_vars = replication_fast_env();
|
||||
@@ -9289,11 +9294,13 @@ async fn test_replication_check_flags_version_minting_target() -> TestResult {
|
||||
fidelity["Code"], "BucketRemoteTargetVersionMismatch",
|
||||
"the failure must carry a machine-readable code: {payload}"
|
||||
);
|
||||
// The probe PUT itself succeeded (fidelity is judged from its response);
|
||||
// the later mutation phases are pointless against a drifting target and
|
||||
// must be skipped, but cleanup still runs.
|
||||
// The probe PUT itself succeeded (fidelity is judged from its response).
|
||||
// The mutation phases address the id the target assigned — the ledger
|
||||
// the worker records per object (rustfs/backlog#2340) — so they run and
|
||||
// pass on a drifting target, and cleanup uses the same id.
|
||||
assert_eq!(target_report["Phases"]["Put"]["Status"], "OK", "{payload}");
|
||||
assert_eq!(target_report["Phases"]["DeleteMarker"]["Status"], "SKIPPED", "{payload}");
|
||||
assert_eq!(target_report["Phases"]["DeleteMarker"]["Status"], "OK", "{payload}");
|
||||
assert_eq!(target_report["Phases"]["VersionDelete"]["Status"], "OK", "{payload}");
|
||||
assert_eq!(target_report["Phases"]["Cleanup"]["Status"], "OK", "{payload}");
|
||||
|
||||
// The probe PUT must carry the source version as `?versionId=` — the
|
||||
@@ -10183,3 +10190,199 @@ async fn test_get_object_tagging_proxies_unreplicated_object_to_replication_targ
|
||||
target.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// backlog#2363
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Wait until the source reports a terminal replication status for `key`.
|
||||
async fn wait_terminal_replication_status(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
ssec: bool,
|
||||
timeout: Duration,
|
||||
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
|
||||
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
let request = client.head_object().bucket(bucket).key(key);
|
||||
let head = if ssec {
|
||||
request
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&customer_key)
|
||||
.sse_customer_key_md5(&customer_key_md5)
|
||||
.send()
|
||||
.await?
|
||||
} else {
|
||||
request.send().await?
|
||||
};
|
||||
let status = head.replication_status().map(|status| status.as_str().to_string());
|
||||
if matches!(status.as_deref(), Some("COMPLETED") | Some("FAILED")) {
|
||||
return Ok(status.unwrap_or_default());
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(format!("{bucket}/{key}: replication never reached a terminal status; last {status:?}").into());
|
||||
}
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// backlog#2363: SSE-C ciphertext passthrough of objects the source stored
|
||||
/// compressed. The replica on a RustFS target must decrypt to the original
|
||||
/// bytes for a single PUT and for a multipart upload.
|
||||
#[tokio::test]
|
||||
async fn test_bucket_replication_sse_c_compressed_passthrough() -> TestResult {
|
||||
init_logging();
|
||||
const PART_SIZE: usize = 5 * 1024 * 1024;
|
||||
|
||||
let mut source_env = RustFSTestEnvironment::new().await?;
|
||||
let mut target_env = RustFSTestEnvironment::new().await?;
|
||||
let mut source_process_env = replication_fast_env();
|
||||
source_process_env.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||
source_process_env.extend_from_slice(FAST_SCANNER_ENV);
|
||||
source_process_env.extend_from_slice(&[
|
||||
("NO_PROXY", "127.0.0.1,localhost"),
|
||||
("HTTP_PROXY", ""),
|
||||
("HTTPS_PROXY", ""),
|
||||
("RUSTFS_COMPRESSION_ENABLED", "true"),
|
||||
("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true"),
|
||||
]);
|
||||
source_env.start_rustfs_server_with_env(vec![], &source_process_env).await?;
|
||||
target_env
|
||||
.start_rustfs_server_without_cleanup_with_env(&[
|
||||
("NO_PROXY", "127.0.0.1,localhost"),
|
||||
("HTTP_PROXY", ""),
|
||||
("HTTPS_PROXY", ""),
|
||||
])
|
||||
.await?;
|
||||
|
||||
let source_bucket = "ssec-compressed-src";
|
||||
let target_bucket = "ssec-compressed-dst";
|
||||
let source_client = source_env.create_s3_client();
|
||||
let target_client = target_env.create_s3_client();
|
||||
source_client.create_bucket().bucket(source_bucket).send().await?;
|
||||
target_client.create_bucket().bucket(target_bucket).send().await?;
|
||||
enable_bucket_versioning(&source_env, source_bucket).await?;
|
||||
enable_bucket_versioning(&target_env, target_bucket).await?;
|
||||
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
|
||||
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
|
||||
|
||||
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
|
||||
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
||||
let text = |len: usize, seed: u32| -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(len + 64);
|
||||
let mut line = 0u64;
|
||||
while out.len() < len {
|
||||
out.extend_from_slice(format!("ssec compressed passthrough seed={seed} line={line} lorem ipsum dolor\n").as_bytes());
|
||||
line += 1;
|
||||
}
|
||||
out.truncate(len);
|
||||
out
|
||||
};
|
||||
|
||||
let single_key = "ssec-compressed-single.txt";
|
||||
let single_body = text(1024 * 1024 + 17, 1);
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key(single_key)
|
||||
.content_type("text/plain")
|
||||
.body(ByteStream::from(single_body.clone()))
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&customer_key)
|
||||
.sse_customer_key_md5(&customer_key_md5)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let multipart_key = "ssec-compressed-multipart.txt";
|
||||
let multipart_parts = [text(PART_SIZE, 2), text(1024 * 1024 + 4096, 3)];
|
||||
let multipart_body: Vec<u8> = multipart_parts.concat();
|
||||
let created = source_client
|
||||
.create_multipart_upload()
|
||||
.bucket(source_bucket)
|
||||
.key(multipart_key)
|
||||
.content_type("text/plain")
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&customer_key)
|
||||
.sse_customer_key_md5(&customer_key_md5)
|
||||
.send()
|
||||
.await?;
|
||||
let upload_id = created.upload_id().ok_or("missing multipart upload id")?.to_string();
|
||||
let mut completed = Vec::new();
|
||||
for (index, part) in multipart_parts.iter().enumerate() {
|
||||
let part_number = i32::try_from(index + 1)?;
|
||||
let uploaded = source_client
|
||||
.upload_part()
|
||||
.bucket(source_bucket)
|
||||
.key(multipart_key)
|
||||
.upload_id(&upload_id)
|
||||
.part_number(part_number)
|
||||
.body(ByteStream::from(part.clone()))
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&customer_key)
|
||||
.sse_customer_key_md5(&customer_key_md5)
|
||||
.send()
|
||||
.await?;
|
||||
completed.push(
|
||||
CompletedPart::builder()
|
||||
.part_number(part_number)
|
||||
.set_e_tag(uploaded.e_tag().map(str::to_string))
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
source_client
|
||||
.complete_multipart_upload()
|
||||
.bucket(source_bucket)
|
||||
.key(multipart_key)
|
||||
.upload_id(&upload_id)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed)).build())
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&customer_key)
|
||||
.sse_customer_key_md5(&customer_key_md5)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let mut failures = Vec::new();
|
||||
for (key, body) in [(single_key, &single_body), (multipart_key, &multipart_body)] {
|
||||
let status = wait_terminal_replication_status(&source_client, source_bucket, key, true, Duration::from_secs(120)).await?;
|
||||
if status != "COMPLETED" {
|
||||
failures.push(format!("{key}: source reports {status}"));
|
||||
continue;
|
||||
}
|
||||
let replica = target_client
|
||||
.get_object()
|
||||
.bucket(target_bucket)
|
||||
.key(key)
|
||||
.sse_customer_algorithm("AES256")
|
||||
.sse_customer_key(&customer_key)
|
||||
.sse_customer_key_md5(&customer_key_md5)
|
||||
.send()
|
||||
.await;
|
||||
match replica {
|
||||
Ok(replica) => {
|
||||
let content_length = replica.content_length();
|
||||
match replica.body.collect().await {
|
||||
Ok(collected) => {
|
||||
let bytes = collected.into_bytes();
|
||||
if bytes.as_ref() != body.as_slice() {
|
||||
failures.push(format!(
|
||||
"{key}: replica bytes differ (content_length={content_length:?}, got {} bytes, want {})",
|
||||
bytes.len(),
|
||||
body.len()
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(err) => failures.push(format!("{key}: replica body read failed: {err}")),
|
||||
}
|
||||
}
|
||||
Err(err) => failures.push(format!("{key}: replica GET failed: {err}")),
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"SSE-C compressed passthrough replicas must decrypt to the source bytes: {failures:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -36,14 +36,16 @@ use crate::fake_s3_target::{BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY};
|
||||
use crate::fake_s3_target::{FakeS3Target, FaultAction as FakeTargetFault, Operation as FakeTargetOperation, RequestRecord};
|
||||
use crate::on_demand_migration::common::{OdmEnvOptions, OdmTestEnv, fake_source_client};
|
||||
use crate::replication_extension_test::{
|
||||
LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, enable_bucket_versioning, get_replication_reset_status,
|
||||
put_bucket_replication, set_replication_target_with_options, start_bucket_replication_reset,
|
||||
LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, delete_bucket_replication, enable_bucket_versioning,
|
||||
get_replication_reset_status, put_bucket_replication, put_bucket_replication_with_delete_statuses,
|
||||
set_replication_target_with_options, start_bucket_replication_reset,
|
||||
};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::{ByteStream, DateTime};
|
||||
use aws_sdk_s3::types::{
|
||||
Checksum, ChecksumAlgorithm, CompletedMultipartUpload, CompletedPart, ObjectAttributes, ObjectLockLegalHoldStatus,
|
||||
ObjectLockMode,
|
||||
Checksum, ChecksumAlgorithm, CompletedMultipartUpload, CompletedPart, ObjectAttributes, ObjectLockLegalHold,
|
||||
ObjectLockLegalHoldStatus, ObjectLockMode, ObjectLockRetention, ObjectLockRetentionMode, Tag, Tagging,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use std::error::Error;
|
||||
@@ -66,7 +68,10 @@ enum TargetMode {
|
||||
/// Object Lock parameters must carry `Content-MD5` or `x-amz-checksum-*`.
|
||||
RequireChecksumWithObjectLock,
|
||||
/// AWS S3 / Wasabi / Impossible Cloud: mints its own version ids
|
||||
/// (rustfs/backlog#2085). Data must still land.
|
||||
/// (rustfs/backlog#2085) and, like Wasabi, answers NoSuchVersion to a
|
||||
/// DELETE of an id it never had (rustfs/backlog#2340). Data must still
|
||||
/// land, and every version-addressed mutation must resolve the replica
|
||||
/// through the target-version ledger.
|
||||
MintOwnVersionIds,
|
||||
}
|
||||
|
||||
@@ -83,7 +88,10 @@ impl TargetMode {
|
||||
TargetMode::Baseline => {}
|
||||
TargetMode::RejectAwsChunked => target.reject_aws_chunked_uploads(true),
|
||||
TargetMode::RequireChecksumWithObjectLock => target.require_checksum_for_object_lock(true),
|
||||
TargetMode::MintOwnVersionIds => target.assign_own_version_ids(true),
|
||||
TargetMode::MintOwnVersionIds => {
|
||||
target.assign_own_version_ids(true);
|
||||
target.reject_unknown_version_deletes(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,6 +392,431 @@ async fn matrix_mint_own_version_ids_redrive_does_not_duplicate() -> TestResult
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// rustfs/backlog#2340 (target-version ledger): on a target that mints its own
|
||||
/// version ids and answers NoSuchVersion to an unknown id (the Wasabi shape),
|
||||
/// every version-addressed mutation must land on the version the target
|
||||
/// assigned, which the replication PUT recorded on the source:
|
||||
/// - a tag update changes the existing target version, no new version;
|
||||
/// - a retention extension and legal hold ON/OFF change that version too;
|
||||
/// - a permanent delete of the older of two same-content generations removes
|
||||
/// exactly that replica and keeps the live one (content identity alone
|
||||
/// could not tell them apart).
|
||||
#[tokio::test]
|
||||
async fn matrix_mint_own_version_ids_addresses_mutations_through_the_ledger() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let target = FakeS3Target::start().await?;
|
||||
let target_bucket = "matrix-mint-own-ledger-dst".to_string();
|
||||
target.create_bucket_with_object_lock(target_bucket.clone());
|
||||
TargetMode::MintOwnVersionIds.apply(&target);
|
||||
|
||||
let mut env_vars = replication_fast_env();
|
||||
env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||
env_vars.extend_from_slice(&[
|
||||
("NO_PROXY", "127.0.0.1,localhost"),
|
||||
("HTTP_PROXY", ""),
|
||||
("HTTPS_PROXY", ""),
|
||||
// The scanner heal pass retries a purge the first attempt lost.
|
||||
("RUSTFS_SCANNER_CYCLE", "1"),
|
||||
("RUSTFS_SCANNER_START_DELAY_SECS", "1"),
|
||||
]);
|
||||
let env = OdmTestEnv::start_with(OdmEnvOptions {
|
||||
env: env_vars,
|
||||
..OdmEnvOptions::default()
|
||||
})
|
||||
.await?;
|
||||
let source_env = &env.rustfs;
|
||||
|
||||
let source_bucket = "matrix-mint-own-ledger-src";
|
||||
let source_client = source_env.create_s3_client();
|
||||
source_client
|
||||
.create_bucket()
|
||||
.bucket(source_bucket)
|
||||
.object_lock_enabled_for_bucket(true)
|
||||
.send()
|
||||
.await?;
|
||||
enable_bucket_versioning(source_env, source_bucket).await?;
|
||||
let target_arn = set_replication_target_with_options(
|
||||
source_env,
|
||||
source_bucket,
|
||||
ReplicationTargetOptions {
|
||||
endpoint: &target.address(),
|
||||
access_key: FAKE_ACCESS_KEY,
|
||||
secret_key: FAKE_SECRET_KEY,
|
||||
target_bucket: &target_bucket,
|
||||
secure: false,
|
||||
skip_tls_verify: false,
|
||||
ca_cert_pem: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
put_bucket_replication_with_delete_statuses(source_env, source_bucket, &target_arn, "Enabled", Some("Enabled")).await?;
|
||||
let target_client = fake_source_client(&target);
|
||||
|
||||
// Tag update on an existing version.
|
||||
let tag_key = "ledger/tags.bin";
|
||||
let tagged = source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key(tag_key)
|
||||
.body(ByteStream::from(payload(4 * 1024, 0x01)))
|
||||
.send()
|
||||
.await?;
|
||||
let tag_source_version = tagged.version_id().ok_or("source PUT returned no version id")?.to_string();
|
||||
assert_eq!(
|
||||
wait_for_terminal_replication_status(&source_client, source_bucket, tag_key).await?,
|
||||
"COMPLETED"
|
||||
);
|
||||
let tag_target_version = single_target_version(&target, &target_bucket, tag_key)?;
|
||||
source_client
|
||||
.put_object_tagging()
|
||||
.bucket(source_bucket)
|
||||
.key(tag_key)
|
||||
.version_id(&tag_source_version)
|
||||
.tagging(
|
||||
Tagging::builder()
|
||||
.tag_set(Tag::builder().key("phase").value("after").build()?)
|
||||
.build()?,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
wait_until("tag update on the existing target version", || async {
|
||||
let tags = target_client
|
||||
.get_object_tagging()
|
||||
.bucket(&target_bucket)
|
||||
.key(tag_key)
|
||||
.version_id(&tag_target_version)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(tags
|
||||
.tag_set()
|
||||
.iter()
|
||||
.any(|tag| tag.key() == "phase" && tag.value() == "after"))
|
||||
})
|
||||
.await?;
|
||||
assert_stable_single_version(&target, &target_bucket, tag_key, &tag_target_version).await?;
|
||||
|
||||
// Retention extension and legal hold on an existing version.
|
||||
let lock_key = "ledger/lock.bin";
|
||||
let locked = source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key(lock_key)
|
||||
.body(ByteStream::from(payload(4 * 1024, 0x02)))
|
||||
.object_lock_mode(ObjectLockMode::Governance)
|
||||
.object_lock_retain_until_date(retain_until())
|
||||
.send()
|
||||
.await?;
|
||||
let lock_source_version = locked.version_id().ok_or("source PUT returned no version id")?.to_string();
|
||||
assert_eq!(
|
||||
wait_for_terminal_replication_status(&source_client, source_bucket, lock_key).await?,
|
||||
"COMPLETED"
|
||||
);
|
||||
let lock_target_version = single_target_version(&target, &target_bucket, lock_key)?;
|
||||
let extended = DateTime::from_secs(retain_until().secs() + 86_400);
|
||||
source_client
|
||||
.put_object_retention()
|
||||
.bucket(source_bucket)
|
||||
.key(lock_key)
|
||||
.version_id(&lock_source_version)
|
||||
.retention(
|
||||
ObjectLockRetention::builder()
|
||||
.mode(ObjectLockRetentionMode::Governance)
|
||||
.retain_until_date(extended)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
source_client
|
||||
.put_object_legal_hold()
|
||||
.bucket(source_bucket)
|
||||
.key(lock_key)
|
||||
.version_id(&lock_source_version)
|
||||
.legal_hold(ObjectLockLegalHold::builder().status(ObjectLockLegalHoldStatus::On).build())
|
||||
.send()
|
||||
.await?;
|
||||
wait_until("retention extension and legal hold on the existing target version", || async {
|
||||
let head = target_client
|
||||
.head_object()
|
||||
.bucket(&target_bucket)
|
||||
.key(lock_key)
|
||||
.version_id(&lock_target_version)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(head.object_lock_retain_until_date().map(|date| date.secs()) == Some(extended.secs())
|
||||
&& head.object_lock_legal_hold_status() == Some(&ObjectLockLegalHoldStatus::On))
|
||||
})
|
||||
.await?;
|
||||
source_client
|
||||
.put_object_legal_hold()
|
||||
.bucket(source_bucket)
|
||||
.key(lock_key)
|
||||
.version_id(&lock_source_version)
|
||||
.legal_hold(ObjectLockLegalHold::builder().status(ObjectLockLegalHoldStatus::Off).build())
|
||||
.send()
|
||||
.await?;
|
||||
wait_until("legal hold removal on the existing target version", || async {
|
||||
let head = target_client
|
||||
.head_object()
|
||||
.bucket(&target_bucket)
|
||||
.key(lock_key)
|
||||
.version_id(&lock_target_version)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(head.object_lock_legal_hold_status() == Some(&ObjectLockLegalHoldStatus::Off))
|
||||
})
|
||||
.await?;
|
||||
assert_stable_single_version(&target, &target_bucket, lock_key, &lock_target_version).await?;
|
||||
|
||||
// Permanent delete of the older of two same-content generations.
|
||||
let generations_key = "ledger/generations.bin";
|
||||
let body = payload(4 * 1024, 0x03);
|
||||
let older = source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key(generations_key)
|
||||
.body(ByteStream::from(body.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
let older_version = older.version_id().ok_or("source PUT returned no version id")?.to_string();
|
||||
assert_eq!(
|
||||
wait_for_terminal_replication_status(&source_client, source_bucket, generations_key).await?,
|
||||
"COMPLETED"
|
||||
);
|
||||
let older_replica = single_target_version(&target, &target_bucket, generations_key)?;
|
||||
source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key(generations_key)
|
||||
.body(ByteStream::from(body))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
wait_for_terminal_replication_status(&source_client, source_bucket, generations_key).await?,
|
||||
"COMPLETED"
|
||||
);
|
||||
wait_until("both generations replicated", || async {
|
||||
Ok(target.stored_versions(&target_bucket, generations_key).len() == 2)
|
||||
})
|
||||
.await?;
|
||||
let newer_replica = target
|
||||
.stored_versions(&target_bucket, generations_key)
|
||||
.into_iter()
|
||||
.map(|(version_id, _)| version_id)
|
||||
.find(|version_id| version_id != &older_replica)
|
||||
.ok_or("the second generation must have its own target version")?;
|
||||
|
||||
source_client
|
||||
.delete_object()
|
||||
.bucket(source_bucket)
|
||||
.key(generations_key)
|
||||
.version_id(&older_version)
|
||||
.send()
|
||||
.await?;
|
||||
wait_until("permanent delete of the older generation's replica", || async {
|
||||
let versions: Vec<String> = target
|
||||
.stored_versions(&target_bucket, generations_key)
|
||||
.into_iter()
|
||||
.map(|(version_id, _)| version_id)
|
||||
.collect();
|
||||
Ok(versions == [newer_replica.clone()])
|
||||
})
|
||||
.await?;
|
||||
assert_stable_single_version(&target, &target_bucket, generations_key, &newer_replica).await?;
|
||||
|
||||
// No mutation above may have gone out as a re-PUT: one upload per key.
|
||||
for key in [tag_key, lock_key] {
|
||||
let puts = target
|
||||
.requests()
|
||||
.iter()
|
||||
.filter(|record| record.key.as_deref() == Some(key) && record.operation == FakeTargetOperation::PutObject)
|
||||
.count();
|
||||
assert_eq!(
|
||||
puts, 1,
|
||||
"{key}: a metadata update must not re-PUT the object on a target that mints its own ids"
|
||||
);
|
||||
}
|
||||
|
||||
target.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// rustfs/backlog#2340 (pending purge lifecycle): a permanent delete whose
|
||||
/// replication keeps failing leaves the version in xl.meta as a PENDING purge,
|
||||
/// hidden from listings. Once the bucket's replication configuration is
|
||||
/// removed nothing can ever confirm that purge remotely, so the delete worker
|
||||
/// must settle it locally (abandoned, with the replica left on the former
|
||||
/// target) — otherwise the bucket stays `BucketNotEmpty` forever with a
|
||||
/// residue the client cannot see.
|
||||
#[tokio::test]
|
||||
async fn matrix_removed_replication_config_abandons_pending_purge() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let target = FakeS3Target::start().await?;
|
||||
let target_bucket = "matrix-abandoned-purge-dst".to_string();
|
||||
target.create_bucket_with_object_lock(target_bucket.clone());
|
||||
TargetMode::MintOwnVersionIds.apply(&target);
|
||||
|
||||
let mut env_vars = replication_fast_env();
|
||||
env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||
env_vars.extend_from_slice(&[
|
||||
("NO_PROXY", "127.0.0.1,localhost"),
|
||||
("HTTP_PROXY", ""),
|
||||
("HTTPS_PROXY", ""),
|
||||
// The scanner heal pass is what revisits a pending purge.
|
||||
("RUSTFS_SCANNER_CYCLE", "1"),
|
||||
("RUSTFS_SCANNER_START_DELAY_SECS", "1"),
|
||||
]);
|
||||
let env = OdmTestEnv::start_with(OdmEnvOptions {
|
||||
env: env_vars,
|
||||
..OdmEnvOptions::default()
|
||||
})
|
||||
.await?;
|
||||
let source_env = &env.rustfs;
|
||||
|
||||
let source_bucket = "matrix-abandoned-purge-src";
|
||||
let source_client = source_env.create_s3_client();
|
||||
source_client
|
||||
.create_bucket()
|
||||
.bucket(source_bucket)
|
||||
.object_lock_enabled_for_bucket(true)
|
||||
.send()
|
||||
.await?;
|
||||
enable_bucket_versioning(source_env, source_bucket).await?;
|
||||
let target_arn = set_replication_target_with_options(
|
||||
source_env,
|
||||
source_bucket,
|
||||
ReplicationTargetOptions {
|
||||
endpoint: &target.address(),
|
||||
access_key: FAKE_ACCESS_KEY,
|
||||
secret_key: FAKE_SECRET_KEY,
|
||||
target_bucket: &target_bucket,
|
||||
secure: false,
|
||||
skip_tls_verify: false,
|
||||
ca_cert_pem: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
put_bucket_replication_with_delete_statuses(source_env, source_bucket, &target_arn, "Enabled", Some("Enabled")).await?;
|
||||
|
||||
let key = "purge/orphaned.bin";
|
||||
let put = source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(payload(4 * 1024, 0x07)))
|
||||
.send()
|
||||
.await?;
|
||||
let source_version = put.version_id().ok_or("source PUT returned no version id")?.to_string();
|
||||
assert_eq!(
|
||||
wait_for_terminal_replication_status(&source_client, source_bucket, key).await?,
|
||||
"COMPLETED"
|
||||
);
|
||||
let replica = single_target_version(&target, &target_bucket, key)?;
|
||||
|
||||
// The target refuses every purge: the version stays a pending purge.
|
||||
// More refusals than any scanner cycle can consume within the test.
|
||||
target.inject_for_key(FakeTargetOperation::DeleteObject, key, FakeTargetFault::ResponseStatus(503), 4_000);
|
||||
source_client
|
||||
.delete_object()
|
||||
.bucket(source_bucket)
|
||||
.key(key)
|
||||
.version_id(&source_version)
|
||||
.send()
|
||||
.await?;
|
||||
wait_until("the refused purge to reach the target at least once", || async {
|
||||
Ok(target.count_requests(FakeTargetOperation::DeleteObject, key) >= 1)
|
||||
})
|
||||
.await?;
|
||||
let listed = source_client.list_object_versions().bucket(source_bucket).send().await?;
|
||||
assert!(
|
||||
listed.versions().is_empty() && listed.delete_markers().is_empty(),
|
||||
"a pending purge is hidden from listings: {listed:?}"
|
||||
);
|
||||
let blocked = source_client.delete_bucket().bucket(source_bucket).send().await;
|
||||
assert!(
|
||||
blocked
|
||||
.as_ref()
|
||||
.err()
|
||||
.and_then(|err| err.as_service_error())
|
||||
.is_some_and(|err| err.code() == Some("BucketNotEmpty")),
|
||||
"the hidden pending purge must block DeleteBucket while the target is still configured: {blocked:?}"
|
||||
);
|
||||
|
||||
// Removing the replication configuration orphans the purge; the scanner
|
||||
// heal pass must settle it locally so the bucket becomes deletable.
|
||||
let response = delete_bucket_replication(source_env, source_bucket).await?;
|
||||
assert!(response.status().is_success(), "DeleteBucketReplication: {}", response.status());
|
||||
wait_until("DeleteBucket to succeed once the orphaned purge is abandoned", || async {
|
||||
match source_client.delete_bucket().bucket(source_bucket).send().await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(err) if err.as_service_error().is_some_and(|err| err.code() == Some("BucketNotEmpty")) => Ok(false),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
// Abandoned means abandoned: the replica stays on the former target and,
|
||||
// once the attempts in flight at removal time have drained, no further
|
||||
// purge attempts are sent to it.
|
||||
assert_eq!(
|
||||
single_target_version(&target, &target_bucket, key)?,
|
||||
replica,
|
||||
"an abandoned purge must not touch the replica on the former target"
|
||||
);
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
let settled = target.count_requests(FakeTargetOperation::DeleteObject, key);
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
assert_eq!(
|
||||
target.count_requests(FakeTargetOperation::DeleteObject, key),
|
||||
settled,
|
||||
"purge attempts must stop once the target is no longer configured"
|
||||
);
|
||||
|
||||
target.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn single_target_version(target: &FakeS3Target, target_bucket: &str, key: &str) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
let versions = target.stored_versions(target_bucket, key);
|
||||
match versions.as_slice() {
|
||||
[(version_id, false)] => Ok(version_id.clone()),
|
||||
other => Err(format!("{key}: expected exactly one live target version, got {other:?}").into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The target keeps holding exactly `version_id` for a few scanner cycles: a
|
||||
/// re-driven PUT or a wrong delete would show up here.
|
||||
async fn assert_stable_single_version(target: &FakeS3Target, target_bucket: &str, key: &str, version_id: &str) -> TestResult {
|
||||
for _ in 0..8 {
|
||||
let versions = target.stored_versions(target_bucket, key);
|
||||
if versions.len() != 1 || versions[0].0 != version_id {
|
||||
return Err(
|
||||
format!("{key}: target versions drifted from the single expected replica {version_id}: {versions:?}").into(),
|
||||
);
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_until<F, Fut>(what: &str, mut probe: F) -> TestResult
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<bool, Box<dyn Error + Send + Sync>>>,
|
||||
{
|
||||
let wait = async {
|
||||
loop {
|
||||
if probe().await? {
|
||||
return Ok::<_, Box<dyn Error + Send + Sync>>(());
|
||||
}
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
};
|
||||
timeout(Duration::from_secs(90), wait)
|
||||
.await
|
||||
.map_err(|_| format!("{what} did not happen within 90 seconds"))?
|
||||
}
|
||||
|
||||
/// Wait until `key` is COMPLETED on the source and, for the observation
|
||||
/// window after that, the target still holds exactly one live version of it.
|
||||
async fn wait_for_replication_status_and_single_version(
|
||||
|
||||
@@ -1473,20 +1473,17 @@ fn layout_cases() -> Vec<LayoutCase> {
|
||||
true,
|
||||
),
|
||||
// SSE-C passthrough replicates the stored ciphertext part by part; a
|
||||
// compressible first part is stored well below 5 MiB and a standard
|
||||
// target rejects it with EntityTooSmall. rc.5 fails the same way (see
|
||||
// `rc5_baseline_replicates_multipart_layouts`), so the outcome is
|
||||
// recorded rather than asserted here; tracked as rustfs/backlog#2363.
|
||||
LayoutCase {
|
||||
assert_replication: false,
|
||||
..case(
|
||||
LAYOUT_PLAIN_BUCKET,
|
||||
"plain/ssec-compressed-multipart-2.txt",
|
||||
two.clone(),
|
||||
layout_text(total(&two), 7),
|
||||
true,
|
||||
)
|
||||
},
|
||||
// compressible first part is stored well below 5 MiB, so the sender
|
||||
// declares each part's plaintext length and the target validates the
|
||||
// 5 MiB minimum against it (rustfs/backlog#2363). rc.5 as the sender
|
||||
// still fails this layout (see `rc5_baseline_replicates_multipart_layouts`).
|
||||
case(
|
||||
LAYOUT_PLAIN_BUCKET,
|
||||
"plain/ssec-compressed-multipart-2.txt",
|
||||
two.clone(),
|
||||
layout_text(total(&two), 7),
|
||||
true,
|
||||
),
|
||||
case(
|
||||
LAYOUT_ENCRYPTED_BUCKET,
|
||||
"encrypted/single.bin",
|
||||
@@ -1838,27 +1835,20 @@ async fn direct_upgrade_from_rc5_preserves_multipart_layouts() -> TestResult {
|
||||
transport.uploaded_parts, expected,
|
||||
"{label}: stored parts must replicate as the same multipart layout"
|
||||
);
|
||||
// The current build can drive an existing object twice (two
|
||||
// full CreateMultipartUpload/UploadPart/Complete rounds with
|
||||
// distinct upload ids) while its status is still PENDING; the
|
||||
// rc.5 baseline drives once. That is a scheduling difference,
|
||||
// not a layout one, tracked as rustfs/backlog#2362.
|
||||
assert!(transport.completes >= 1, "{label}: at least one CompleteMultipartUpload");
|
||||
if transport.completes > 1 {
|
||||
tracing::warn!(
|
||||
target: "e2e_test::upgrade_compatibility_test",
|
||||
object = %label,
|
||||
completes = transport.completes,
|
||||
journal = ?transport.journal,
|
||||
"existing-object replication drove the same object more than once (rustfs/backlog#2362)"
|
||||
);
|
||||
}
|
||||
// An object still PENDING when the next scanner cycle arrives is
|
||||
// not driven a second time (rustfs/backlog#2362); the journal is
|
||||
// logged so a duplicate round is visible if this ever regresses.
|
||||
assert_eq!(
|
||||
transport.completes, 1,
|
||||
"{label}: exactly one CompleteMultipartUpload; journal {:?}",
|
||||
transport.journal
|
||||
);
|
||||
assert_eq!(
|
||||
transport.single_puts, 0,
|
||||
"{label}: a multipart layout must not go out as a single PutObject"
|
||||
);
|
||||
} else {
|
||||
assert!(transport.single_puts >= 1, "{label}: a single PUT replicates as PutObject");
|
||||
assert_eq!(transport.single_puts, 1, "{label}: a single PUT replicates as exactly one PutObject");
|
||||
assert!(transport.uploaded_parts.is_empty(), "{label}: a single PUT must not go out as multipart");
|
||||
}
|
||||
if !case.ssec {
|
||||
@@ -1928,3 +1918,59 @@ async fn rc5_baseline_replicates_multipart_layouts() -> TestResult {
|
||||
tracing::info!(target: "e2e_test::upgrade_compatibility_test", ?summary, "rc.5 baseline replication outcomes");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// backlog#2362 under the same conditions that reproduced it with the rc.5
|
||||
/// writer, but with the workspace build on both sides so it runs in the
|
||||
/// ordinary lane: every pre-existing layout is driven through exactly one
|
||||
/// upload round even though the scanner re-scans it every second while the
|
||||
/// first round is still in flight.
|
||||
#[tokio::test]
|
||||
async fn existing_object_replication_drives_each_layout_once() -> TestResult {
|
||||
init_logging();
|
||||
let server_env = layout_server_env();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(vec![], &server_env).await?;
|
||||
let writer = env.create_s3_client();
|
||||
env.create_test_bucket(LAYOUT_PLAIN_BUCKET).await?;
|
||||
env.create_test_bucket(LAYOUT_ENCRYPTED_BUCKET).await?;
|
||||
enable_versioning(&writer, LAYOUT_PLAIN_BUCKET).await?;
|
||||
enable_versioning(&writer, LAYOUT_ENCRYPTED_BUCKET).await?;
|
||||
put_default_sse_s3_encryption(&writer, LAYOUT_ENCRYPTED_BUCKET).await?;
|
||||
|
||||
let mut cases = layout_cases();
|
||||
for case in cases.iter_mut() {
|
||||
layout_write(&writer, case).await?;
|
||||
let head = layout_head(&writer, case).await?;
|
||||
case.rc5_etag = head
|
||||
.e_tag()
|
||||
.ok_or_else(|| format!("{}: HEAD omitted the ETag", case.label()))?
|
||||
.trim_matches('"')
|
||||
.to_string();
|
||||
}
|
||||
|
||||
// The objects come from an earlier process lifetime: the scanner starts
|
||||
// cold and every object is a candidate at once.
|
||||
env.restart_server_preserving_data(vec![], &server_env).await?;
|
||||
let client = env.create_s3_client();
|
||||
let (_target, transports) = replicate_layouts(&env, &client, &cases).await?;
|
||||
let mut duplicates = Vec::new();
|
||||
for (case, transport) in cases.iter().zip(&transports) {
|
||||
assert_eq!(
|
||||
transport.status,
|
||||
"COMPLETED",
|
||||
"{}: existing-object replication must complete",
|
||||
case.label()
|
||||
);
|
||||
let rounds = if case.is_multipart_layout() {
|
||||
transport.completes
|
||||
} else {
|
||||
transport.single_puts
|
||||
};
|
||||
if rounds != 1 {
|
||||
duplicates.push(format!("{}: {rounds} upload rounds; journal {:?}", case.label(), transport.journal));
|
||||
}
|
||||
}
|
||||
assert!(duplicates.is_empty(), "each existing object must be driven exactly once: {duplicates:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ pub mod bucket {
|
||||
pub use crate::bucket::bucket_target_sys::{
|
||||
AdvancedPutOptions, BucketTargetError, BucketTargetSys, PutObjectOptions, RemoveObjectOptions, S3ClientError,
|
||||
SsecPassthroughCapability, TargetClient, VersionIdentityCapability, append_version_id_query,
|
||||
resolve_delete_api_version_id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -415,8 +416,8 @@ pub mod disk {
|
||||
|
||||
pub mod error {
|
||||
pub use crate::error::{
|
||||
Error, Result, StorageError, classify_system_path_failure_reason, is_err_bucket_not_found, is_err_object_not_found,
|
||||
is_err_version_not_found,
|
||||
Error, PoolMetadataError, PoolMetadataFailure, Result, StorageError, classify_system_path_failure_reason,
|
||||
is_err_bucket_not_found, is_err_object_not_found, is_err_version_not_found,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ use aws_sdk_s3::operation::get_object::{GetObjectError, GetObjectOutput};
|
||||
use aws_sdk_s3::operation::get_object_tagging::{GetObjectTaggingError, GetObjectTaggingOutput};
|
||||
use aws_sdk_s3::operation::head_bucket::HeadBucketError;
|
||||
use aws_sdk_s3::operation::head_object::HeadObjectError;
|
||||
use aws_sdk_s3::operation::put_object_legal_hold::{PutObjectLegalHoldError, PutObjectLegalHoldOutput};
|
||||
use aws_sdk_s3::operation::put_object_retention::{PutObjectRetentionError, PutObjectRetentionOutput};
|
||||
use aws_sdk_s3::operation::put_object_tagging::{PutObjectTaggingError, PutObjectTaggingOutput};
|
||||
use aws_sdk_s3::operation::upload_part::UploadPartOutput;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
@@ -42,6 +44,7 @@ use aws_sdk_s3::types::{
|
||||
ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart, ObjectLockLegalHoldStatus, ObjectLockRetentionMode,
|
||||
ServerSideEncryption,
|
||||
};
|
||||
use aws_sdk_s3::types::{ObjectLockLegalHold, ObjectLockRetention};
|
||||
use aws_sdk_s3::{Client as S3Client, operation::head_object::HeadObjectOutput};
|
||||
use aws_smithy_runtime_api::client::orchestrator::HttpRequest;
|
||||
use futures::{StreamExt, stream};
|
||||
@@ -139,9 +142,12 @@ fn same_replication_service(edited: &BucketTarget, previous: &BucketTarget) -> b
|
||||
&& access_key(edited) == access_key(previous)
|
||||
}
|
||||
|
||||
/// Page size and page budget for [`TargetClient::find_version_by_etag`].
|
||||
/// Page size and page budget for [`TargetClient::locate_replica_by_etag`].
|
||||
const FIND_VERSION_BY_ETAG_PAGE_SIZE: i32 = 1000;
|
||||
const FIND_VERSION_BY_ETAG_MAX_PAGES: usize = 8;
|
||||
/// Candidate cap for [`TargetClient::replica_candidates_by_etag`]: more than
|
||||
/// this many same-content versions of one key is ambiguity by any measure.
|
||||
const FIND_VERSION_BY_ETAG_MAX_MATCHES: usize = 16;
|
||||
pub type GetObjectSdkError = Box<SdkError<GetObjectError>>;
|
||||
pub type GetObjectTaggingSdkError = Box<SdkError<GetObjectTaggingError>>;
|
||||
pub type PutObjectTaggingSdkError = Box<SdkError<PutObjectTaggingError>>;
|
||||
@@ -1363,6 +1369,7 @@ fn generate_arn(t: &BucketTarget, depl_id: &str) -> String {
|
||||
arn.to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RemoveObjectOptions {
|
||||
pub force_delete: bool,
|
||||
pub governance_bypass: bool,
|
||||
@@ -1418,7 +1425,12 @@ fn build_remove_object_headers(version_id: Option<&str>, opts: &RemoveObjectOpti
|
||||
/// and silently creates a delete marker instead of removing the version, while
|
||||
/// the source stamps `VersionPurgeStatus=Complete` (backlog#799 B8 / #857).
|
||||
/// Non-replication callers always pass the version through unchanged.
|
||||
fn resolve_delete_api_version_id(version_id: Option<String>, opts: &RemoveObjectOptions) -> Option<String> {
|
||||
/// The `versionId` a replicated DELETE puts on the wire: none for a
|
||||
/// delete-marker creation (the target mints the marker; the source version
|
||||
/// travels in the internal headers for RustFS peers), the addressed version
|
||||
/// otherwise. A generic S3 target given the version id on a marker-creation
|
||||
/// DELETE would permanently delete that version instead.
|
||||
pub fn resolve_delete_api_version_id(version_id: Option<String>, opts: &RemoveObjectOptions) -> Option<String> {
|
||||
if opts.replication_request && opts.replication_delete_marker {
|
||||
None
|
||||
} else {
|
||||
@@ -1971,22 +1983,29 @@ impl TargetClient {
|
||||
.map_err(Box::new)
|
||||
}
|
||||
|
||||
/// Locate a replica by content identity on a target that mints its own
|
||||
/// version ids: page `ListObjectVersions` under the exact key and return
|
||||
/// the newest live version whose ETag matches `source_etag`. Delete
|
||||
/// markers and prefix siblings never match. Bounded to
|
||||
/// [`FIND_VERSION_BY_ETAG_MAX_PAGES`] pages so a key with a very deep
|
||||
/// history cannot turn one convergence check into an unbounded scan; a
|
||||
/// replica beyond that window reads as missing, which only costs a
|
||||
/// Candidate replicas by content identity on a target that mints its own
|
||||
/// version ids: page `ListObjectVersions` under the exact key and report
|
||||
/// the live versions whose ETag matches `source_etag`, newest first.
|
||||
/// Delete markers and prefix siblings never match. Bounded to
|
||||
/// [`FIND_VERSION_BY_ETAG_MAX_PAGES`] pages and
|
||||
/// [`FIND_VERSION_BY_ETAG_MAX_MATCHES`] candidates so a key with a very
|
||||
/// deep history cannot turn one convergence check into an unbounded scan;
|
||||
/// a replica beyond that window reads as missing, which only costs a
|
||||
/// re-PUT (today's behaviour), never a lost object.
|
||||
pub async fn find_version_by_etag(
|
||||
///
|
||||
/// Content identity is not version identity: two source generations with
|
||||
/// the same bytes have the same ETag. Callers drop the candidates other
|
||||
/// source versions already claim through their ledgers and refuse an
|
||||
/// [`ReplicaLocation::Ambiguous`] remainder before mutating or deleting.
|
||||
pub async fn replica_candidates_by_etag(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
source_etag: &str,
|
||||
) -> Result<Option<String>, Box<SdkError<aws_sdk_s3::operation::list_object_versions::ListObjectVersionsError>>> {
|
||||
) -> Result<Vec<String>, Box<SdkError<aws_sdk_s3::operation::list_object_versions::ListObjectVersionsError>>> {
|
||||
let mut key_marker: Option<String> = None;
|
||||
let mut version_id_marker: Option<String> = None;
|
||||
let mut matches: Vec<String> = Vec::new();
|
||||
for _ in 0..FIND_VERSION_BY_ETAG_MAX_PAGES {
|
||||
let page = self
|
||||
.client
|
||||
@@ -1999,32 +2018,88 @@ impl TargetClient {
|
||||
.send()
|
||||
.await
|
||||
.map_err(Box::new)?;
|
||||
if let Some(version) = page.versions().iter().find(|version| {
|
||||
version.key() == Some(object)
|
||||
&& version.version_id().is_some_and(|id| !id.is_empty())
|
||||
&& replication_etags_match(Some(source_etag), version.e_tag())
|
||||
}) {
|
||||
return Ok(version.version_id().map(str::to_string));
|
||||
}
|
||||
// Every listed key is >= the prefix; once the listing moved past
|
||||
// the exact key there is nothing left to find.
|
||||
if page
|
||||
.versions()
|
||||
.iter()
|
||||
.any(|version| version.key().is_some_and(|key| key > object))
|
||||
matches.extend(
|
||||
page.versions()
|
||||
.iter()
|
||||
.filter(|version| {
|
||||
version.key() == Some(object)
|
||||
&& version.version_id().is_some_and(|id| !id.is_empty())
|
||||
&& replication_etags_match(Some(source_etag), version.e_tag())
|
||||
})
|
||||
.filter_map(|version| version.version_id().map(str::to_string)),
|
||||
);
|
||||
// A listing that moved past the exact key (every listed key is >=
|
||||
// the prefix), ended, or already filled the candidate cap decides.
|
||||
if matches.len() >= FIND_VERSION_BY_ETAG_MAX_MATCHES
|
||||
|| page
|
||||
.versions()
|
||||
.iter()
|
||||
.any(|version| version.key().is_some_and(|key| key > object))
|
||||
|| !page.is_truncated().unwrap_or(false)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
if !page.is_truncated().unwrap_or(false) {
|
||||
return Ok(None);
|
||||
break;
|
||||
}
|
||||
key_marker = page.next_key_marker().map(str::to_string);
|
||||
version_id_marker = page.next_version_id_marker().map(str::to_string);
|
||||
if key_marker.is_none() {
|
||||
return Ok(None);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
matches.truncate(FIND_VERSION_BY_ETAG_MAX_MATCHES);
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
/// PutObjectRetention against a replica version on a target that does not
|
||||
/// take retention through the replication PUT's own headers (it mints its
|
||||
/// own version ids, so a re-PUT would create another version instead of
|
||||
/// updating this one). Anti-loop marker always added.
|
||||
pub async fn put_object_retention(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
version_id: Option<String>,
|
||||
mode: ObjectLockRetentionMode,
|
||||
retain_until: aws_sdk_s3::primitives::DateTime,
|
||||
) -> Result<PutObjectRetentionOutput, Box<SdkError<PutObjectRetentionError>>> {
|
||||
let headers = proxy_outbound_headers(HeaderMap::new());
|
||||
self.client
|
||||
.put_object_retention()
|
||||
.bucket(bucket)
|
||||
.key(object)
|
||||
.set_version_id(resolve_read_api_version_id(version_id))
|
||||
.retention(
|
||||
ObjectLockRetention::builder()
|
||||
.mode(mode)
|
||||
.retain_until_date(retain_until)
|
||||
.build(),
|
||||
)
|
||||
.customize()
|
||||
.map_request(move |req| apply_extra_headers(req, &headers))
|
||||
.send()
|
||||
.await
|
||||
.map_err(Box::new)
|
||||
}
|
||||
|
||||
/// PutObjectLegalHold counterpart of [`Self::put_object_retention`].
|
||||
pub async fn put_object_legal_hold(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
version_id: Option<String>,
|
||||
status: ObjectLockLegalHoldStatus,
|
||||
) -> Result<PutObjectLegalHoldOutput, Box<SdkError<PutObjectLegalHoldError>>> {
|
||||
let headers = proxy_outbound_headers(HeaderMap::new());
|
||||
self.client
|
||||
.put_object_legal_hold()
|
||||
.bucket(bucket)
|
||||
.key(object)
|
||||
.set_version_id(resolve_read_api_version_id(version_id))
|
||||
.legal_hold(ObjectLockLegalHold::builder().status(status).build())
|
||||
.customize()
|
||||
.map_request(move |req| apply_extra_headers(req, &headers))
|
||||
.send()
|
||||
.await
|
||||
.map_err(Box::new)
|
||||
}
|
||||
|
||||
/// HEAD used by the read-proxy path (GET/HEAD of an object not yet
|
||||
@@ -2478,6 +2553,45 @@ impl TargetClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a replica stands on a target that mints its own version ids, by
|
||||
/// content identity (exact key + ETag) after the candidates other source
|
||||
/// versions claim were removed. See
|
||||
/// [`TargetClient::replica_candidates_by_etag`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ReplicaLocation {
|
||||
/// No live version under the key carries the source ETag.
|
||||
Missing,
|
||||
/// Exactly one live version carries it: safe to address.
|
||||
Unique(String),
|
||||
/// More than one live version carries it (same bytes replicated for
|
||||
/// several source generations). `newest` is the most recently listed
|
||||
/// one — good enough to prove the replica exists, never good enough to
|
||||
/// pick which one to mutate or delete.
|
||||
Ambiguous { newest: String },
|
||||
}
|
||||
|
||||
impl ReplicaLocation {
|
||||
/// `matches` newest first, as the target listed them.
|
||||
pub fn from_matches(mut matches: Vec<String>) -> Self {
|
||||
match matches.len() {
|
||||
0 => Self::Missing,
|
||||
1 => Self::Unique(matches.remove(0)),
|
||||
_ => Self::Ambiguous {
|
||||
newest: matches.remove(0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The version to read for existence/ETag checks, where an ambiguous
|
||||
/// match is still a located replica.
|
||||
pub fn any_version_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Missing => None,
|
||||
Self::Unique(version_id) | Self::Ambiguous { newest: version_id } => Some(version_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BucketTargetError {
|
||||
BucketRemoteTargetNotFound {
|
||||
|
||||
@@ -54,6 +54,9 @@ pub type BucketConfigPublishHook = Box<dyn Fn(&str, &str, Option<(&[u8], OffsetD
|
||||
pub static BUCKET_CONFIG_PUBLISH_HOOK: std::sync::OnceLock<BucketConfigPublishHook> = std::sync::OnceLock::new();
|
||||
|
||||
const BUCKET_METADATA_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60);
|
||||
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||
const LOG_SUBSYSTEM_BUCKET_METADATA: &str = "bucket_metadata";
|
||||
const EVENT_BUCKET_METADATA_LOAD_FAILED: &str = "bucket_metadata_load_failed";
|
||||
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
struct ConfigWriteLockProbeState {
|
||||
@@ -1614,13 +1617,20 @@ impl BucketMetadataSys {
|
||||
|
||||
let results = join_all(futures).await;
|
||||
|
||||
for (idx, res) in results.into_iter().enumerate() {
|
||||
for (bucket, res) in buckets.iter().zip(results) {
|
||||
match res {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
error!("Unable to load bucket metadata, will be retried: {:?}", e);
|
||||
if let Some(bucket) = buckets.get(idx) {
|
||||
failed_buckets.insert(bucket.clone());
|
||||
if failed_buckets.insert(bucket.clone()) {
|
||||
error!(
|
||||
event = EVENT_BUCKET_METADATA_LOAD_FAILED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_BUCKET_METADATA,
|
||||
result = "retry_pending",
|
||||
bucket = %bucket,
|
||||
error_code = ?e.code(),
|
||||
"Unable to load bucket metadata; retry scheduled"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1647,12 +1657,19 @@ impl BucketMetadataSys {
|
||||
});
|
||||
}
|
||||
let results = join_all(futures).await;
|
||||
for (idx, result) in results.into_iter().enumerate() {
|
||||
if let Err(err) = result {
|
||||
error!("Unable to load bucket metadata, will be retried: {:?}", err);
|
||||
if let Some(bucket) = buckets.get(idx) {
|
||||
failed_buckets.insert(bucket.clone());
|
||||
}
|
||||
for (bucket, result) in buckets.iter().zip(results) {
|
||||
if let Err(err) = result
|
||||
&& failed_buckets.insert(bucket.clone())
|
||||
{
|
||||
error!(
|
||||
event = EVENT_BUCKET_METADATA_LOAD_FAILED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_BUCKET_METADATA,
|
||||
result = "retry_pending",
|
||||
bucket = %bucket,
|
||||
error_code = ?err.code(),
|
||||
"Unable to load bucket metadata; retry scheduled"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ use tracing::{debug, info, instrument, warn};
|
||||
const EVENT_REPLICATION_WORKER_RESIZE_SKIPPED: &str = "replication_worker_resize_skipped";
|
||||
const EVENT_REPLICATION_WORKER_RESIZED: &str = "replication_worker_resized";
|
||||
const EVENT_REPLICATION_BACKPRESSURE: &str = "replication_backpressure";
|
||||
const EVENT_REPLICATION_IN_FLIGHT_SKIPPED: &str = "replication_in_flight_skipped";
|
||||
const EVENT_REPLICATION_RESYNC_LOAD_SKIPPED: &str = "replication_resync_load_skipped";
|
||||
const EVENT_REPLICATION_RESYNC_RECOVERED: &str = "replication_resync_recovered";
|
||||
const EVENT_REPLICATION_MRF_QUEUE_UNAVAILABLE: &str = "replication_mrf_queue_unavailable";
|
||||
@@ -1089,6 +1090,9 @@ pub struct ReplicationPool<S: ReplicationStorage> {
|
||||
workers: RwLock<Vec<Sender<ReplicationOperation>>>,
|
||||
lrg_workers: RwLock<Vec<Sender<ReplicationOperation>>>,
|
||||
|
||||
/// Object versions queued or being replicated right now (backlog#2362).
|
||||
in_flight: Arc<ReplicationInFlight>,
|
||||
|
||||
// MRF (Most Recent Failures) channels
|
||||
mrf_replica_tx: Sender<ReplicationOperation>,
|
||||
// Shared among N MRF workers; Arc allows spawning more than one worker.
|
||||
@@ -1147,6 +1151,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
storage,
|
||||
workers: RwLock::new(Vec::new()),
|
||||
lrg_workers: RwLock::new(Vec::new()),
|
||||
in_flight: Arc::new(ReplicationInFlight::default()),
|
||||
mrf_replica_tx,
|
||||
mrf_replica_rx: Arc::new(Mutex::new(mrf_replica_rx)),
|
||||
mrf_save_tx,
|
||||
@@ -1202,12 +1207,13 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
let active_counter = self.active_lrg_workers.clone();
|
||||
let storage = self.storage.clone();
|
||||
let stats = self.stats.clone();
|
||||
let in_flight = self.in_flight.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut rx = rx;
|
||||
while let Some(operation) = rx.recv().await {
|
||||
let _active = ActiveWorkerGuard::new(active_counter.clone());
|
||||
process_replication_operation(operation, stats.clone(), storage.clone()).await;
|
||||
process_replication_operation(operation, stats.clone(), storage.clone(), in_flight.clone()).await;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1261,12 +1267,13 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
let active_counter = self.active_workers.clone();
|
||||
let stats = self.stats.clone();
|
||||
let storage = self.storage.clone();
|
||||
let in_flight = self.in_flight.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut rx = rx;
|
||||
while let Some(operation) = rx.recv().await {
|
||||
let _active = ActiveWorkerGuard::new(active_counter.clone());
|
||||
process_replication_operation(operation, stats.clone(), storage.clone()).await;
|
||||
process_replication_operation(operation, stats.clone(), storage.clone(), in_flight.clone()).await;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1305,6 +1312,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
let active_counter = self.active_mrf_workers.clone();
|
||||
let stats = self.stats.clone();
|
||||
let storage = self.storage.clone();
|
||||
let in_flight = self.in_flight.clone();
|
||||
let mrf_rx = Arc::clone(&self.mrf_replica_rx);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
@@ -1324,7 +1332,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
let Some(operation) = operation else { break };
|
||||
|
||||
let _active = ActiveWorkerGuard::new(active_counter.clone());
|
||||
process_replication_operation(operation, stats.clone(), storage.clone()).await;
|
||||
process_replication_operation(operation, stats.clone(), storage.clone(), in_flight.clone()).await;
|
||||
}
|
||||
});
|
||||
self.task_handles.lock().await.push(handle);
|
||||
@@ -1454,6 +1462,24 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
|
||||
/// Queues a replica task
|
||||
pub async fn queue_replica_task(&self, ri: ReplicateObjectInfo) -> ReplicationQueueAdmission {
|
||||
// A version that is already queued or being uploaded is not driven a
|
||||
// second time: the scanner heal pass sees it as PENDING until the
|
||||
// first upload lands and would otherwise re-queue it every cycle
|
||||
// (backlog#2362). The key is released when the worker finishes, or
|
||||
// below when no worker accepts the task.
|
||||
if !self.in_flight.try_begin(&ri) {
|
||||
debug!(
|
||||
event = EVENT_REPLICATION_IN_FLIGHT_SKIPPED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REPLICATION,
|
||||
bucket = %ri.bucket,
|
||||
object = %ri.name,
|
||||
version_id = ?ri.version_id,
|
||||
op_type = ?ri.op_type,
|
||||
"Replication task already in flight; not queued again"
|
||||
);
|
||||
return ReplicationQueueAdmission::Skipped;
|
||||
}
|
||||
let target_arns = ri.dsc.replicate_target_arns();
|
||||
// If object is large, queue it to a static set of large workers
|
||||
if should_queue_large_object(ri.size) {
|
||||
@@ -1484,7 +1510,9 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
let resize = large_worker_backpressure_resize(existing, self.active_lrg_workers(), max_l_workers);
|
||||
drop(lrg_workers);
|
||||
|
||||
// Queue to MRF if worker is busy.
|
||||
// Queue to MRF if worker is busy. The MRF replay re-enters
|
||||
// this function, so the version is no longer in flight.
|
||||
self.in_flight.finish(&ri);
|
||||
let admission = self.queue_mrf_save_admission(ri.to_mrf_entry(), "large_object").await;
|
||||
|
||||
if let Some(resize) = resize {
|
||||
@@ -1493,6 +1521,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
return admission;
|
||||
}
|
||||
}
|
||||
self.in_flight.finish(&ri);
|
||||
return ReplicationQueueAdmission::Missed;
|
||||
}
|
||||
|
||||
@@ -1501,6 +1530,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
let ch = self.worker_queue_channel(&ri.op_type, &ri.bucket, &ri.name, ri.size).await;
|
||||
|
||||
let Some(channel) = ch else {
|
||||
self.in_flight.finish(&ri);
|
||||
return ReplicationQueueAdmission::Missed;
|
||||
};
|
||||
|
||||
@@ -1512,7 +1542,9 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
self.stats.dec_q(&ri.bucket, ri.size, ri.delete_marker, ri.op_type);
|
||||
self.stats.dec_target_q(&ri.bucket, &target_arns, ri.size);
|
||||
|
||||
// Queue to MRF if all workers are busy.
|
||||
// Queue to MRF if all workers are busy. The MRF replay re-enters this
|
||||
// function, so the version is no longer in flight.
|
||||
self.in_flight.finish(&ri);
|
||||
let admission = self.queue_mrf_save_admission(ri.to_mrf_entry(), "object").await;
|
||||
|
||||
// Try to scale up workers based on priority
|
||||
@@ -1811,7 +1843,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
) {
|
||||
while let Some(operation) = rx.recv().await {
|
||||
let _active = ActiveWorkerGuard::new(active_counter.clone());
|
||||
process_replication_operation(operation, stats.clone(), self.storage.clone()).await;
|
||||
process_replication_operation(operation, stats.clone(), self.storage.clone(), self.in_flight.clone()).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1829,7 +1861,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
) {
|
||||
while let Some(operation) = rx.recv().await {
|
||||
let _active = ActiveWorkerGuard::new(active_counter.clone());
|
||||
process_replication_operation(operation, stats.clone(), storage.clone()).await;
|
||||
process_replication_operation(operation, stats.clone(), storage.clone(), self.in_flight.clone()).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1846,7 +1878,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
) {
|
||||
while let Some(operation) = rx.recv().await {
|
||||
let _active = ActiveWorkerGuard::new(active_counter.clone());
|
||||
process_replication_operation(operation, stats.clone(), self.storage.clone()).await;
|
||||
process_replication_operation(operation, stats.clone(), self.storage.clone(), self.in_flight.clone()).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2281,6 +2313,64 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Object versions currently queued or being uploaded, keyed by bucket,
|
||||
/// object name and version. `queue_replica_task` admits a version only once
|
||||
/// while it is in flight; the scanner heal pass and MRF replays that arrive
|
||||
/// in the meantime are `Skipped` instead of driving a second complete upload
|
||||
/// (backlog#2362). Entries are removed when the worker finishes the task or
|
||||
/// when no worker accepted it.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct ReplicationInFlight {
|
||||
keys: std::sync::Mutex<std::collections::HashSet<(String, String, Option<uuid::Uuid>)>>,
|
||||
}
|
||||
|
||||
impl ReplicationInFlight {
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, std::collections::HashSet<(String, String, Option<uuid::Uuid>)>> {
|
||||
self.keys.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
/// Claim `ri`; `false` when the same version is already in flight.
|
||||
fn try_begin(&self, ri: &ReplicateObjectInfo) -> bool {
|
||||
self.lock().insert((ri.bucket.clone(), ri.name.clone(), ri.version_id))
|
||||
}
|
||||
|
||||
fn finish(&self, ri: &ReplicateObjectInfo) {
|
||||
self.lock().remove(&(ri.bucket.clone(), ri.name.clone(), ri.version_id));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn len(&self) -> usize {
|
||||
self.lock().len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Releases the in-flight claim when the worker is done with the task,
|
||||
/// including when replication panics.
|
||||
struct ReplicationInFlightGuard {
|
||||
in_flight: Arc<ReplicationInFlight>,
|
||||
key: ReplicateObjectInfo,
|
||||
}
|
||||
|
||||
impl ReplicationInFlightGuard {
|
||||
fn new(in_flight: Arc<ReplicationInFlight>, ri: &ReplicateObjectInfo) -> Self {
|
||||
Self {
|
||||
in_flight,
|
||||
key: ReplicateObjectInfo {
|
||||
bucket: ri.bucket.clone(),
|
||||
name: ri.name.clone(),
|
||||
version_id: ri.version_id,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ReplicationInFlightGuard {
|
||||
fn drop(&mut self) {
|
||||
self.in_flight.finish(&self.key);
|
||||
}
|
||||
}
|
||||
|
||||
struct ActiveWorkerGuard {
|
||||
counter: Arc<AtomicI32>,
|
||||
}
|
||||
@@ -2342,10 +2432,12 @@ async fn process_replication_operation<S: ReplicationStorage>(
|
||||
operation: ReplicationOperation,
|
||||
stats: Arc<ReplicationStats>,
|
||||
storage: Arc<S>,
|
||||
in_flight: Arc<ReplicationInFlight>,
|
||||
) {
|
||||
match operation {
|
||||
ReplicationOperation::Object(obj_info) => {
|
||||
let _backlog = ReplicationBacklogGuard::for_object(stats, obj_info.as_ref());
|
||||
let _in_flight = ReplicationInFlightGuard::new(in_flight, obj_info.as_ref());
|
||||
replicate_object(*obj_info, storage).await;
|
||||
}
|
||||
ReplicationOperation::Delete(del_info) => {
|
||||
@@ -3079,7 +3171,11 @@ pub async fn queue_replication_heal(bucket: &str, oi: ObjectInfo, retry_count: u
|
||||
}
|
||||
|
||||
let rcfg = match ReplicationMetadataStore::optional_replication_config(bucket).await {
|
||||
Ok(Some(config)) => config,
|
||||
Ok(Some(config)) => Some(config),
|
||||
// A bucket without a configuration still owes its pending purges an
|
||||
// answer: the delete worker finishes them locally as abandoned, which
|
||||
// is what makes the bucket deletable again (rustfs/backlog#2340).
|
||||
Ok(None) if owes_version_purge(&oi) => None,
|
||||
Ok(None) => return ReplicationQueueAdmission::Skipped,
|
||||
Err(err) => {
|
||||
debug!(
|
||||
@@ -3129,7 +3225,7 @@ pub async fn queue_replication_heal(bucket: &str, oi: ObjectInfo, retry_count: u
|
||||
}
|
||||
};
|
||||
|
||||
let rcfg_wrapper = ReplicationConfig::new(Some(rcfg), tgts);
|
||||
let rcfg_wrapper = ReplicationConfig::new(rcfg, tgts);
|
||||
queue_replication_heal_internal(bucket, oi, rcfg_wrapper, retry_count)
|
||||
.await
|
||||
.admission
|
||||
@@ -3157,6 +3253,17 @@ pub async fn queue_replication_metadata(bucket: &str, oi: ObjectInfo, retry_coun
|
||||
}
|
||||
}
|
||||
|
||||
/// A version purge the persisted state still owes to named targets. Without
|
||||
/// the target list nothing can be settled, so such a version keeps the
|
||||
/// ordinary "no configuration, nothing to heal" skip.
|
||||
fn owes_version_purge(oi: &ObjectInfo) -> bool {
|
||||
!oi.version_purge_status.is_empty()
|
||||
&& oi
|
||||
.version_purge_status_internal
|
||||
.as_deref()
|
||||
.is_some_and(|statuses| !statuses.trim().is_empty())
|
||||
}
|
||||
|
||||
/// queue_replication_heal_internal enqueues objects that failed replication OR eligible for resyncing through
|
||||
/// an ongoing resync operation or via existing objects replication configuration setting.
|
||||
pub(crate) async fn queue_replication_heal_internal(
|
||||
@@ -3175,7 +3282,11 @@ pub(crate) async fn queue_replication_heal_internal(
|
||||
};
|
||||
}
|
||||
|
||||
if rcfg.config.is_none() || rcfg.remotes.is_none() {
|
||||
// Without a configuration or targets there is nothing to replicate —
|
||||
// except a version purge the bucket still owes: its stored decision names
|
||||
// the targets, and the delete worker settles the ones no longer
|
||||
// configured as abandoned (rustfs/backlog#2340).
|
||||
if (rcfg.config.is_none() || rcfg.remotes.is_none()) && !owes_version_purge(&oi) {
|
||||
return ReplicationHealQueueResult {
|
||||
object_info: roi,
|
||||
admission: ReplicationQueueAdmission::Skipped,
|
||||
@@ -3220,12 +3331,15 @@ pub(crate) async fn queue_replication_heal_internal(
|
||||
}
|
||||
ReplicationHealQueueAction::QueueDelete(dv) => {
|
||||
// A purge the peer denied under object lock cannot succeed until
|
||||
// the lock lapses (#6850); requeuing it every heal cycle only
|
||||
// the lock lapses (#6850), and one whose replica cannot be told
|
||||
// apart on a target that mints its own version ids cannot
|
||||
// succeed until the ledger or an operator resolves it
|
||||
// (rustfs/backlog#2340); requeuing either every heal cycle only
|
||||
// burns bandwidth and failure counters. The backoff expires on
|
||||
// its own, so the purge is probed again — and converges — once
|
||||
// the retention window has a chance of being over.
|
||||
// the condition has a chance of being over.
|
||||
if super::replication_object_decision_boundary::is_version_delete_replication(&dv.delete_object)
|
||||
&& super::replication_resyncer::object_lock_denied_purge_backoff_active(&dv)
|
||||
&& super::replication_resyncer::purge_backoff_active(&dv)
|
||||
{
|
||||
return ReplicationHealQueueResult {
|
||||
object_info: roi,
|
||||
@@ -3707,6 +3821,7 @@ mod tests {
|
||||
stats: Arc::new(ReplicationStats::new()),
|
||||
workers: RwLock::new(Vec::new()),
|
||||
lrg_workers: RwLock::new(Vec::new()),
|
||||
in_flight: Arc::new(ReplicationInFlight::default()),
|
||||
mrf_replica_tx,
|
||||
mrf_replica_rx: Arc::new(Mutex::new(mrf_replica_rx)),
|
||||
mrf_save_tx,
|
||||
@@ -3773,6 +3888,90 @@ mod tests {
|
||||
assert_eq!(current_queue(&pool, "admission-bucket").await, (1, 4096));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queue_replica_task_admits_a_version_once_while_it_is_in_flight() {
|
||||
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await;
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
pool.workers.write().await.push(tx);
|
||||
let ri = ReplicateObjectInfo {
|
||||
bucket: "in-flight-bucket".to_string(),
|
||||
name: "object".to_string(),
|
||||
version_id: Some(uuid::Uuid::new_v4()),
|
||||
size: 4096,
|
||||
op_type: ReplicationType::Object,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(pool.queue_replica_task(ri.clone()).await, ReplicationQueueAdmission::Queued);
|
||||
// backlog#2362: the scanner heal pass sees the version as PENDING
|
||||
// until the worker lands it; a second request must not drive it again.
|
||||
assert_eq!(pool.queue_replica_task(ri.clone()).await, ReplicationQueueAdmission::Skipped);
|
||||
assert_eq!(current_queue(&pool, "in-flight-bucket").await, (1, 4096));
|
||||
|
||||
// Another version of the same key is independent work.
|
||||
let newer = ReplicateObjectInfo {
|
||||
version_id: Some(uuid::Uuid::new_v4()),
|
||||
..ri.clone()
|
||||
};
|
||||
assert_eq!(pool.queue_replica_task(newer).await, ReplicationQueueAdmission::Queued);
|
||||
assert_eq!(pool.in_flight.len(), 2);
|
||||
|
||||
// Once the worker finishes, the same version may be queued again
|
||||
// (for example after a FAILED status).
|
||||
pool.in_flight.finish(&ri);
|
||||
assert_eq!(pool.queue_replica_task(ri).await, ReplicationQueueAdmission::Queued);
|
||||
assert_eq!(current_queue(&pool, "in-flight-bucket").await, (3, 3 * 4096));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queue_replica_task_releases_the_version_when_no_worker_accepts_it() {
|
||||
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await;
|
||||
let ri = ReplicateObjectInfo {
|
||||
bucket: "no-worker-bucket".to_string(),
|
||||
name: "object".to_string(),
|
||||
version_id: Some(uuid::Uuid::new_v4()),
|
||||
size: 4096,
|
||||
op_type: ReplicationType::Object,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// No worker channel: the task is missed and must not stay claimed.
|
||||
assert_eq!(pool.queue_replica_task(ri.clone()).await, ReplicationQueueAdmission::Missed);
|
||||
assert_eq!(pool.in_flight.len(), 0);
|
||||
assert_eq!(pool.queue_replica_task(ri.clone()).await, ReplicationQueueAdmission::Missed);
|
||||
|
||||
// A full worker channel hands the task to the MRF save path; the MRF
|
||||
// replay re-enters the queue, so the claim is released here too.
|
||||
let (tx, _rx) = mpsc::channel(1);
|
||||
pool.workers.write().await.push(tx);
|
||||
assert_eq!(pool.queue_replica_task(ri.clone()).await, ReplicationQueueAdmission::Queued);
|
||||
let overflow = ReplicateObjectInfo {
|
||||
version_id: Some(uuid::Uuid::new_v4()),
|
||||
..ri
|
||||
};
|
||||
assert_eq!(pool.queue_replica_task(overflow).await, ReplicationQueueAdmission::Queued);
|
||||
assert_eq!(pool.in_flight.len(), 1, "only the version held by the worker channel stays in flight");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_flight_guard_releases_the_version_on_drop() {
|
||||
let in_flight = Arc::new(ReplicationInFlight::default());
|
||||
let ri = ReplicateObjectInfo {
|
||||
bucket: "guard-bucket".to_string(),
|
||||
name: "object".to_string(),
|
||||
version_id: Some(uuid::Uuid::new_v4()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(in_flight.try_begin(&ri));
|
||||
assert!(!in_flight.try_begin(&ri));
|
||||
{
|
||||
let _guard = ReplicationInFlightGuard::new(in_flight.clone(), &ri);
|
||||
assert_eq!(in_flight.len(), 1);
|
||||
}
|
||||
assert_eq!(in_flight.len(), 0);
|
||||
assert!(in_flight.try_begin(&ri));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn regular_worker_admission_counts_target_backlog_before_receive() {
|
||||
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@ use time::format_description::well_known::Rfc3339;
|
||||
|
||||
pub(crate) use crate::bucket::bucket_target_sys::{
|
||||
AdvancedPutOptions, HeadObjectSdkError, PutObjectOptions, PutObjectPartOptions, RemotePutObjectResponse, RemoveObjectOptions,
|
||||
S3ClientError, TargetClient, resolve_read_api_version_id,
|
||||
ReplicaLocation, S3ClientError, TargetClient, resolve_read_api_version_id,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use crate::bucket::target::BucketTarget;
|
||||
@@ -247,6 +247,23 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo)
|
||||
meta.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
|
||||
// A compressed SSE-C object passes through as its stored bytes. The target
|
||||
// cannot infer the compression layout from ciphertext, so the scheme and
|
||||
// the plaintext size travel as transport headers; each UploadPart carries
|
||||
// its own plaintext length (backlog#2363).
|
||||
if is_ssec && let Some(scheme) = get_str(&object_info.user_defined, rustfs_utils::http::SUFFIX_COMPRESSION) {
|
||||
insert_header_map(&mut meta, rustfs_utils::http::SUFFIX_REPLICATION_COMPRESSION, scheme);
|
||||
if let Ok(actual_size) = object_info.get_actual_size()
|
||||
&& actual_size >= 0
|
||||
{
|
||||
insert_header_map(
|
||||
&mut meta,
|
||||
rustfs_utils::http::SUFFIX_REPLICATION_COMPRESSION_ACTUAL_SIZE,
|
||||
actual_size.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Managed SSE replicates as plaintext (the replication reader decrypts via
|
||||
// the object-encryption resolver) and re-encrypts on the target with the
|
||||
// target's own KMS. Send only the encryption intent — never the source
|
||||
@@ -626,6 +643,59 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_ssec_objects_declare_their_compression_layout_on_the_wire() {
|
||||
use rustfs_utils::http::{
|
||||
SUFFIX_ACTUAL_SIZE, SUFFIX_COMPRESSION, SUFFIX_REPLICATION_COMPRESSION, SUFFIX_REPLICATION_COMPRESSION_ACTUAL_SIZE,
|
||||
insert_str,
|
||||
};
|
||||
|
||||
let mut ssec_compressed = HashMap::from([(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string())]);
|
||||
insert_str(&mut ssec_compressed, SUFFIX_COMPRESSION, "klauspost/compress/s2".to_string());
|
||||
insert_str(&mut ssec_compressed, SUFFIX_ACTUAL_SIZE, "6295552".to_string());
|
||||
let object_info = ObjectInfo {
|
||||
etag: Some("0123456789abcdef0123456789abcdef-2".to_string()),
|
||||
size: 4321,
|
||||
actual_size: 6295552,
|
||||
user_defined: Arc::new(ssec_compressed),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// SSE-C passthrough sends stored bytes: the scheme and the plaintext
|
||||
// size travel as transport headers, never as the internal key
|
||||
// (backlog#2363).
|
||||
let (options, _) = replication_put_object_options("STANDARD", &object_info).expect("ssec put options");
|
||||
assert_eq!(
|
||||
get_header_map(&options.user_metadata, SUFFIX_REPLICATION_COMPRESSION).as_deref(),
|
||||
Some("klauspost/compress/s2")
|
||||
);
|
||||
assert_eq!(
|
||||
get_header_map(&options.user_metadata, SUFFIX_REPLICATION_COMPRESSION_ACTUAL_SIZE).as_deref(),
|
||||
Some("6295552")
|
||||
);
|
||||
assert!(
|
||||
!options
|
||||
.user_metadata
|
||||
.keys()
|
||||
.any(|key| rustfs_utils::http::is_internal_key(key)),
|
||||
"internal metadata never leaves the source as plain metadata: {:?}",
|
||||
options.user_metadata
|
||||
);
|
||||
|
||||
// A compressed object that is not SSE-C is decompressed by the
|
||||
// replication reader and travels as plaintext: no layout headers.
|
||||
let mut plain_compressed = HashMap::new();
|
||||
insert_str(&mut plain_compressed, SUFFIX_COMPRESSION, "klauspost/compress/s2".to_string());
|
||||
insert_str(&mut plain_compressed, SUFFIX_ACTUAL_SIZE, "6295552".to_string());
|
||||
let plain = ObjectInfo {
|
||||
user_defined: Arc::new(plain_compressed),
|
||||
..object_info
|
||||
};
|
||||
let (options, _) = replication_put_object_options("STANDARD", &plain).expect("plain put options");
|
||||
assert!(get_header_map(&options.user_metadata, SUFFIX_REPLICATION_COMPRESSION).is_none());
|
||||
assert!(get_header_map(&options.user_metadata, SUFFIX_REPLICATION_COMPRESSION_ACTUAL_SIZE).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_transformed_single_put_parts_keep_the_previous_replication_route() {
|
||||
let [_, (_, compressed), (_, encrypted), (_, ssec)] = replication_route_metadata();
|
||||
|
||||
@@ -747,18 +747,27 @@ mod tests {
|
||||
let mut kvs = KVS::new();
|
||||
kvs.insert(CLASS_STANDARD.to_string(), "EC:2".to_string());
|
||||
|
||||
let err = lookup_config_for_pools_with_env(&kvs, &[4, 2], no_env_overrides())
|
||||
.expect_err("EC:2 must be rejected by the two-drive pool");
|
||||
assert!(
|
||||
err.to_string().contains("pool 1") && err.to_string().contains("2 drives"),
|
||||
"error must identify the rejecting pool: {err}"
|
||||
);
|
||||
for drives in [2, 3] {
|
||||
let err = lookup_config_for_pools_with_env(&kvs, &[4, drives], no_env_overrides())
|
||||
.expect_err("EC:2 must be rejected by a pool with fewer than four drives per set");
|
||||
assert!(
|
||||
err.to_string().contains("pool 1") && err.to_string().contains(&format!("{drives} drives")),
|
||||
"error must identify the rejecting pool: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
let cfg =
|
||||
lookup_config_for_pools_with_env(&kvs, &[4, 4], no_env_overrides()).expect("EC:2 is valid for both four-drive pools");
|
||||
assert_eq!(cfg.parities_for_sc(STANDARD), Some(vec![2, 2]));
|
||||
|
||||
kvs.insert(CLASS_STANDARD.to_string(), "EC:1".to_string());
|
||||
let cfg = lookup_config_for_pools_with_env(&kvs, &[4, 2], no_env_overrides()).expect("EC:1 is valid for both pools");
|
||||
assert_eq!(cfg.parity_for_sc(STANDARD, 4), Some(1));
|
||||
assert_eq!(cfg.parity_for_sc(STANDARD, 2), Some(1));
|
||||
assert_eq!(cfg.get_parity_for_sc(STANDARD), Some(1));
|
||||
for drives in [2, 3, 4] {
|
||||
let cfg =
|
||||
lookup_config_for_pools_with_env(&kvs, &[4, drives], no_env_overrides()).expect("EC:1 is valid for both pools");
|
||||
assert_eq!(cfg.parity_for_sc(STANDARD, 4), Some(1));
|
||||
assert_eq!(cfg.parity_for_sc(STANDARD, drives), Some(1));
|
||||
assert_eq!(cfg.get_parity_for_sc(STANDARD), Some(1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+1757
-239
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,7 @@ mod capacity_dedup_tests {
|
||||
..Default::default()
|
||||
},
|
||||
disks: disks.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let total = get_total_usable_capacity(&disks, &info);
|
||||
@@ -73,6 +74,7 @@ mod capacity_dedup_tests {
|
||||
..Default::default()
|
||||
},
|
||||
disks: disks.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let total = get_total_usable_capacity(&disks, &info);
|
||||
@@ -150,6 +152,7 @@ mod capacity_dedup_tests {
|
||||
..Default::default()
|
||||
},
|
||||
disks: disks.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let total = get_total_usable_capacity(&disks, &info);
|
||||
|
||||
@@ -425,6 +425,7 @@ impl From<rustfs_filemeta::Error> for DiskError {
|
||||
rustfs_filemeta::Error::FileVersionNotFound => DiskError::FileVersionNotFound,
|
||||
rustfs_filemeta::Error::FileCorrupt => DiskError::FileCorrupt,
|
||||
rustfs_filemeta::Error::MethodNotAllowed => DiskError::MethodNotAllowed,
|
||||
rustfs_filemeta::Error::MaxVersionsExceeded => DiskError::MaxVersionsExceeded,
|
||||
e => DiskError::other(e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,17 +23,59 @@ use s3s::S3ErrorCode;
|
||||
pub type Error = StorageError;
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PoolMetadataFailure {
|
||||
ReadUnavailable,
|
||||
RecoveryRequired,
|
||||
TransactionUnknown,
|
||||
FenceLost,
|
||||
}
|
||||
|
||||
impl PoolMetadataFailure {
|
||||
fn recovery_hint(self) -> &'static str {
|
||||
match self {
|
||||
Self::ReadUnavailable => "read unavailable; retry after the replicas are readable",
|
||||
Self::TransactionUnknown => "writes remain blocked pending fenced transaction recovery",
|
||||
Self::RecoveryRequired | Self::FenceLost => {
|
||||
"writes remain blocked after a recovery-required replica state; restart after all replicas are readable and consistent, with compatible formats"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::ReadUnavailable => "read_unavailable",
|
||||
Self::RecoveryRequired => "recovery_required",
|
||||
Self::TransactionUnknown => "transaction_unknown",
|
||||
Self::FenceLost => "fence_lost",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Local control-plane context. Keep the existing storage error wire codes;
|
||||
/// the HTTP boundary recognizes this typed source, not an error-message prefix.
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
#[error("{operation}: pool metadata {hint} ({reason}, {phase}): {detail}", hint = kind.recovery_hint(), reason = kind.as_str(), detail = source.as_ref().map(ToString::to_string).unwrap_or_default())]
|
||||
pub struct PoolMetadataError {
|
||||
pub kind: PoolMetadataFailure,
|
||||
pub operation: String,
|
||||
pub phase: &'static str,
|
||||
pub since: time::OffsetDateTime,
|
||||
#[source]
|
||||
pub source: Option<std::sync::Arc<StorageError>>,
|
||||
}
|
||||
|
||||
/// Keeps high-cardinality diagnostic detail in the error source while making
|
||||
/// the rendered `io::Error` stable for quorum aggregation.
|
||||
#[derive(Debug)]
|
||||
struct StableIoContextError {
|
||||
message: &'static str,
|
||||
message: std::borrow::Cow<'static, str>,
|
||||
source: Box<dyn std::error::Error + Send + Sync>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StableIoContextError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(self.message)
|
||||
formatter.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +90,7 @@ where
|
||||
E: Into<Box<dyn std::error::Error + Send + Sync>>,
|
||||
{
|
||||
std::io::Error::other(StableIoContextError {
|
||||
message,
|
||||
message: message.into(),
|
||||
source: source.into(),
|
||||
})
|
||||
}
|
||||
@@ -203,6 +245,19 @@ pub enum StorageError {
|
||||
NotFirstDisk,
|
||||
#[error("first disk wait")]
|
||||
FirstDiskWait,
|
||||
#[error(
|
||||
"unsupported pool expansion: an existing single-node single-drive (SNSD) deployment cannot be expanded in place (configured {configured_drives} drive endpoints); restart with the original single local path, or create a new multi-drive deployment and migrate data through S3"
|
||||
)]
|
||||
UnsupportedSnsdExpansion { configured_drives: usize },
|
||||
#[error(
|
||||
"pool topology mismatch: stored {stored_drives} drives with {stored_set_drive_count} drives per erasure set, configured {configured_drives} drives with {configured_set_drive_count} drives per erasure set; an existing pool's drive count and erasure set width cannot be changed in place; restore its original endpoints and RUSTFS_ERASURE_SET_DRIVE_COUNT setting; to expand a multi-drive deployment, append a new pool with at least 2 drive endpoints"
|
||||
)]
|
||||
PoolTopologyMismatch {
|
||||
stored_drives: usize,
|
||||
stored_set_drive_count: usize,
|
||||
configured_drives: usize,
|
||||
configured_set_drive_count: usize,
|
||||
},
|
||||
|
||||
// ── Operational ──────────────────────────────────────────────────
|
||||
#[error("Storage reached its minimum free drive threshold.")]
|
||||
@@ -287,6 +342,22 @@ impl From<crate::erasure::coding::ErasureConstructionError> for StorageError {
|
||||
}
|
||||
|
||||
impl StorageError {
|
||||
pub fn pool_metadata_failure(&self) -> Option<&PoolMetadataError> {
|
||||
let mut current: Option<&(dyn std::error::Error + 'static)> = Some(self);
|
||||
while let Some(error) = current {
|
||||
if let Some(context) = error.downcast_ref::<PoolMetadataError>() {
|
||||
return Some(context);
|
||||
}
|
||||
// io::Error::source skips its boxed context itself.
|
||||
current = if let Some(io) = error.downcast_ref::<std::io::Error>() {
|
||||
io.get_ref().map(|inner| inner as &(dyn std::error::Error + 'static))
|
||||
} else {
|
||||
error.source()
|
||||
};
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn other<E>(error: E) -> Self
|
||||
where
|
||||
E: Into<Box<dyn std::error::Error + Send + Sync>>,
|
||||
@@ -517,6 +588,7 @@ impl From<rustfs_filemeta::Error> for StorageError {
|
||||
rustfs_filemeta::Error::FileVersionNotFound => StorageError::FileVersionNotFound,
|
||||
rustfs_filemeta::Error::FileCorrupt => StorageError::FileCorrupt,
|
||||
rustfs_filemeta::Error::Unexpected => StorageError::Unexpected,
|
||||
rustfs_filemeta::Error::MaxVersionsExceeded => StorageError::MaxVersionsExceeded,
|
||||
rustfs_filemeta::Error::Io(io_error) => io_error.into(),
|
||||
_ => StorageError::Io(std::io::Error::other(e)),
|
||||
}
|
||||
@@ -535,7 +607,19 @@ impl PartialEq for StorageError {
|
||||
impl Clone for StorageError {
|
||||
fn clone(&self) -> Self {
|
||||
match self {
|
||||
StorageError::Io(e) => StorageError::Io(std::io::Error::new(e.kind(), e.to_string())),
|
||||
StorageError::Io(e) => {
|
||||
if let Some(context) = self.pool_metadata_failure() {
|
||||
Self::Io(std::io::Error::new(
|
||||
e.kind(),
|
||||
StableIoContextError {
|
||||
message: e.to_string().into(),
|
||||
source: Box::new(context.clone()),
|
||||
},
|
||||
))
|
||||
} else {
|
||||
StorageError::Io(std::io::Error::new(e.kind(), e.to_string()))
|
||||
}
|
||||
}
|
||||
StorageError::FaultyDisk => StorageError::FaultyDisk,
|
||||
StorageError::DiskFull => StorageError::DiskFull,
|
||||
StorageError::VolumeNotFound => StorageError::VolumeNotFound,
|
||||
@@ -629,6 +713,20 @@ impl Clone for StorageError {
|
||||
StorageError::ErasureWriteQuorum => StorageError::ErasureWriteQuorum,
|
||||
StorageError::NotFirstDisk => StorageError::NotFirstDisk,
|
||||
StorageError::FirstDiskWait => StorageError::FirstDiskWait,
|
||||
StorageError::UnsupportedSnsdExpansion { configured_drives } => StorageError::UnsupportedSnsdExpansion {
|
||||
configured_drives: *configured_drives,
|
||||
},
|
||||
StorageError::PoolTopologyMismatch {
|
||||
stored_drives,
|
||||
stored_set_drive_count,
|
||||
configured_drives,
|
||||
configured_set_drive_count,
|
||||
} => StorageError::PoolTopologyMismatch {
|
||||
stored_drives: *stored_drives,
|
||||
stored_set_drive_count: *stored_set_drive_count,
|
||||
configured_drives: *configured_drives,
|
||||
configured_set_drive_count: *configured_set_drive_count,
|
||||
},
|
||||
StorageError::TooManyOpenFiles => StorageError::TooManyOpenFiles,
|
||||
StorageError::NoHealRequired => StorageError::NoHealRequired,
|
||||
StorageError::Lock(e) => StorageError::Lock(e.clone()),
|
||||
@@ -662,7 +760,8 @@ impl Clone for StorageError {
|
||||
}
|
||||
|
||||
impl StorageError {
|
||||
fn code(&self) -> StorageErrorCode {
|
||||
/// Stable classification without error payloads or storage paths.
|
||||
pub fn code(&self) -> StorageErrorCode {
|
||||
match self {
|
||||
StorageError::Io(_) => StorageErrorCode::Io,
|
||||
StorageError::FaultyDisk => StorageErrorCode::FaultyDisk,
|
||||
@@ -735,6 +834,11 @@ impl StorageError {
|
||||
StorageError::ErasureWriteQuorum => StorageErrorCode::ErasureWriteQuorum,
|
||||
StorageError::NotFirstDisk => StorageErrorCode::NotFirstDisk,
|
||||
StorageError::FirstDiskWait => StorageErrorCode::FirstDiskWait,
|
||||
// Topology diagnostics reuse the existing wire code; they are
|
||||
// not disk errors and must retain their local identity for retry classification.
|
||||
StorageError::UnsupportedSnsdExpansion { .. } | StorageError::PoolTopologyMismatch { .. } => {
|
||||
StorageErrorCode::InvalidArgument
|
||||
}
|
||||
StorageError::ConfigNotFound => StorageErrorCode::ConfigNotFound,
|
||||
StorageError::TooManyOpenFiles => StorageErrorCode::TooManyOpenFiles,
|
||||
StorageError::NoHealRequired => StorageErrorCode::NoHealRequired,
|
||||
@@ -1215,6 +1319,29 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::{Error as IoError, ErrorKind};
|
||||
|
||||
#[test]
|
||||
fn startup_topology_errors_preserve_identity_and_guidance() {
|
||||
for error in [
|
||||
StorageError::UnsupportedSnsdExpansion { configured_drives: 4 },
|
||||
StorageError::PoolTopologyMismatch {
|
||||
stored_drives: 4,
|
||||
stored_set_drive_count: 4,
|
||||
configured_drives: 8,
|
||||
configured_set_drive_count: 8,
|
||||
},
|
||||
] {
|
||||
let io_error: IoError = error.clone().into();
|
||||
let restored = StorageError::from(io_error);
|
||||
assert_eq!(std::mem::discriminant(&restored), std::mem::discriminant(&error));
|
||||
assert_eq!(restored.to_string(), error.to_string());
|
||||
assert_eq!(restored.code(), StorageErrorCode::InvalidArgument);
|
||||
assert!(
|
||||
restored.narrow_to_disk().is_err(),
|
||||
"startup diagnostics must not become disk/quorum errors"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_preserves_erasure_construction_source_chain() {
|
||||
use crate::erasure::coding::ErasureConstructionError;
|
||||
|
||||
@@ -25,6 +25,20 @@ pub(crate) const MAX_ERASURE_SET_DRIVE_COUNT: usize = 16;
|
||||
const SET_SIZES: [usize; 15] = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, MAX_ERASURE_SET_DRIVE_COUNT];
|
||||
const ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT: &str = "RUSTFS_ERASURE_SET_DRIVE_COUNT";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum PoolDriveCountError {
|
||||
#[error(
|
||||
"Incorrect number of endpoints provided, size {size}; an erasure pool requires at least {} drive endpoints on one or more nodes; for a standalone single-drive deployment, use a single local path without ellipses",
|
||||
SET_SIZES[0]
|
||||
)]
|
||||
BelowMinimum { size: usize },
|
||||
#[error(
|
||||
"Incorrect number of endpoints provided, size {size}; {}={set_drive_count} requires at least {set_drive_count} drive endpoints per pool",
|
||||
ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT
|
||||
)]
|
||||
BelowSetWidth { size: usize, set_drive_count: usize },
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Default)]
|
||||
pub struct PoolDisksLayout {
|
||||
cmd_line: String,
|
||||
@@ -132,7 +146,7 @@ impl DisksLayout {
|
||||
for arg in args.iter() {
|
||||
if !has_ellipses(&[arg]) && args.len() > 1 {
|
||||
return Err(Error::other(
|
||||
"all args must have ellipses for pool expansion (Invalid arguments specified)",
|
||||
"all args must have ellipses for pool expansion (Invalid arguments specified); each pool must expand to at least 2 drive endpoints on one or more nodes; a single-drive pool cannot be added to a multi-pool deployment",
|
||||
));
|
||||
}
|
||||
|
||||
@@ -396,9 +410,11 @@ fn get_set_indexes<T: AsRef<str>>(
|
||||
}
|
||||
|
||||
for &size in total_sizes {
|
||||
// Check if total_sizes has minimum range upto set_size
|
||||
if size < SET_SIZES[0] || size < set_drive_count {
|
||||
return Err(Error::other(format!("Incorrect number of endpoints provided, size {size}")));
|
||||
if size < SET_SIZES[0] {
|
||||
return Err(Error::other(PoolDriveCountError::BelowMinimum { size }));
|
||||
}
|
||||
if size < set_drive_count {
|
||||
return Err(Error::other(PoolDriveCountError::BelowSetWidth { size, set_drive_count }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -707,7 +723,7 @@ mod test {
|
||||
arg: "http://rustfs{2...3}/export/set{1...0}",
|
||||
..Default::default()
|
||||
},
|
||||
// Range cannot be smaller than 4 minimum.
|
||||
// Ranges must use three dots.
|
||||
TestCase {
|
||||
num: 4,
|
||||
arg: "/export{1..2}",
|
||||
@@ -926,11 +942,146 @@ mod test {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_expansion_accepts_single_node_multi_drive_pools() {
|
||||
temp_env::with_var(ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT, Some("0"), || {
|
||||
for (volumes, drives) in [
|
||||
(["http://node1:9000/data{1...2}", "http://node2:9000/data{1...2}"], 2),
|
||||
(["http://node1:9000/data{1...4}", "http://node2:9000/data{1...4}"], 4),
|
||||
(["http://node{1...4}:9000/data", "http://node5:9000/data{1...4}"], 4),
|
||||
(["http://node5:9000/data{1...4}", "http://node{1...4}:9000/data"], 4),
|
||||
] {
|
||||
let layout = DisksLayout::from_volumes(&volumes).expect("single-node multi-drive pools are valid");
|
||||
|
||||
assert!(!layout.legacy);
|
||||
assert_eq!(layout.pools.len(), 2);
|
||||
for (index, volume) in volumes.iter().enumerate() {
|
||||
assert_eq!(layout.get_set_count(index), 1);
|
||||
assert_eq!(layout.get_drives_per_set(index), drives);
|
||||
assert_eq!(layout.get_cmd_line(index), *volume);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_expansion_accepts_multi_node_single_drive_pools() {
|
||||
temp_env::with_var(ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT, Some("0"), || {
|
||||
for nodes in [2, 3, 4] {
|
||||
let volumes = [
|
||||
format!("http://pool1-node{{1...{nodes}}}:9000/data"),
|
||||
format!("http://pool2-node{{1...{nodes}}}:9000/data"),
|
||||
];
|
||||
let layout = DisksLayout::from_volumes(&volumes).expect("each node may contribute one drive to a pool");
|
||||
|
||||
assert_eq!(layout.pools.len(), 2);
|
||||
for pool in 0..2 {
|
||||
assert_eq!(layout.get_set_count(pool), 1);
|
||||
assert_eq!(layout.get_drives_per_set(pool), nodes);
|
||||
let expected = (1..=nodes)
|
||||
.map(|node| format!("http://pool{}-node{node}:9000/data", pool + 1))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(layout.pools[pool].layout, vec![expected]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_endpoints_without_ellipses_form_one_pool() {
|
||||
temp_env::with_var(ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT, Some("0"), || {
|
||||
let volumes = ["http://node1:9000/data", "http://node2:9000/data"];
|
||||
let layout = DisksLayout::from_volumes(&volumes).expect("explicit endpoints form one legacy pool");
|
||||
|
||||
assert!(layout.legacy);
|
||||
assert_eq!(layout.pools.len(), 1);
|
||||
assert_eq!(layout.pools[0].layout, vec![volumes.to_vec()]);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_single_drive_path_remains_supported() {
|
||||
temp_env::with_var(ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT, Some("0"), || {
|
||||
let layout = DisksLayout::from_volumes(&["/data"]).expect("standalone single-drive deployment is valid");
|
||||
|
||||
assert!(layout.is_single_drive_layout());
|
||||
assert_eq!(layout.get_single_drive_layout(), "/data");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_expansion_rejects_plain_single_drive_pool_with_notice() {
|
||||
temp_env::with_var(ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT, Some("0"), || {
|
||||
for volumes in [
|
||||
["http://node{1...2}:9000/data", "http://node3:9000/data"],
|
||||
["http://node3:9000/data", "http://node{1...2}:9000/data"],
|
||||
] {
|
||||
let err = DisksLayout::from_volumes(&volumes).expect_err("a plain endpoint cannot be an expansion pool");
|
||||
let message = err.to_string();
|
||||
|
||||
assert!(message.contains("all args must have ellipses for pool expansion"), "{message}");
|
||||
assert!(message.contains("at least 2 drive endpoints"), "{message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_expansion_rejects_singleton_ellipsis_pool_with_notice() {
|
||||
temp_env::with_var(ENV_RUSTFS_ERASURE_SET_DRIVE_COUNT, Some("0"), || {
|
||||
for singleton in ["http://node{3...3}:9000/data", "http://node3:9000/data{1...1}"] {
|
||||
for volumes in [
|
||||
vec!["http://node{1...2}:9000/data", singleton],
|
||||
vec![singleton, "http://node{1...2}:9000/data"],
|
||||
vec![singleton],
|
||||
] {
|
||||
let err = DisksLayout::from_volumes(&volumes).expect_err("a singleton range still contains one drive");
|
||||
let message = err.to_string();
|
||||
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::Other);
|
||||
assert!(matches!(
|
||||
err.get_ref().and_then(|source| source.downcast_ref::<PoolDriveCountError>()),
|
||||
Some(PoolDriveCountError::BelowMinimum { size: 1 })
|
||||
));
|
||||
assert!(message.contains("at least 2 drive endpoints"), "{message}");
|
||||
assert!(message.contains("single local path without ellipses"), "{message}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_set_size_counts_drives_not_nodes() {
|
||||
for volume in ["http://node1:9000/data{1...4}", "http://node{1...4}:9000/data"] {
|
||||
let sets = get_all_sets(2, true, &[volume]).expect("four endpoints can form two two-drive sets");
|
||||
assert_eq!(sets.iter().map(Vec::len).collect::<Vec<_>>(), vec![2, 2]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undersized_pool_error_identifies_requested_set_size() {
|
||||
let err =
|
||||
get_all_sets(4, true, &["http://node{1...2}:9000/data"]).expect_err("two endpoints cannot fill a four-drive set");
|
||||
let message = err.to_string();
|
||||
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::Other);
|
||||
assert!(matches!(
|
||||
err.get_ref().and_then(|source| source.downcast_ref::<PoolDriveCountError>()),
|
||||
Some(PoolDriveCountError::BelowSetWidth {
|
||||
size: 2,
|
||||
set_drive_count: 4
|
||||
})
|
||||
));
|
||||
assert!(message.contains("size 2"), "{message}");
|
||||
assert!(message.contains("RUSTFS_ERASURE_SET_DRIVE_COUNT=4"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_errors_do_not_echo_url_credentials() {
|
||||
for volumes in [
|
||||
vec!["http://:duplicate-secret@server/path", "http://:duplicate-secret@server/path"],
|
||||
vec!["http://:ellipsis...secret@server/path"],
|
||||
vec!["http://server{1...2}/data", "http://:plain-secret@server3/data"],
|
||||
vec!["http://server{1...2}/data", "http://:singleton-secret@server{3...3}/data"],
|
||||
] {
|
||||
let err = DisksLayout::from_volumes(&volumes).unwrap_err();
|
||||
assert!(!err.to_string().contains("secret"), "layout error leaked endpoint credentials: {err}");
|
||||
|
||||
@@ -2432,6 +2432,41 @@ mod test {
|
||||
assert_eq!(local_endpoints[0].pool_idx, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_expansion_resolves_single_node_multi_drive_and_multi_node_single_drive_pools() {
|
||||
for (additional_pool, expected_nodes) in [
|
||||
("http://rustfs-5.example.invalid:9000/data{1...4}", 5),
|
||||
("http://rustfs-{5...8}.example.invalid:9000/data", 8),
|
||||
] {
|
||||
let layout = temp_env::with_var("RUSTFS_ERASURE_SET_DRIVE_COUNT", Some("0"), || {
|
||||
DisksLayout::from_volumes(&["http://rustfs-{1...4}.example.invalid:9000/data", additional_pool])
|
||||
})
|
||||
.expect("both single-node multi-drive and multi-node single-drive pools should parse");
|
||||
|
||||
let (pools, setup_type) = EndpointServerPools::create_server_endpoints_with(
|
||||
"0.0.0.0:9000",
|
||||
&layout,
|
||||
Some(orchestrated_test_policy()),
|
||||
Some("rustfs-1.example.invalid"),
|
||||
)
|
||||
.await
|
||||
.expect("pool admission must not impose a minimum node count or drives per node");
|
||||
|
||||
assert_eq!(setup_type, SetupType::DistErasure);
|
||||
assert_eq!(pools.0.len(), 2);
|
||||
assert_eq!(pools.get_nodes().len(), expected_nodes);
|
||||
for (pool_index, pool) in (0_i32..).zip(&pools.0) {
|
||||
assert_eq!((pool.set_count, pool.drives_per_set), (1, 4));
|
||||
assert_eq!(pool.endpoints.as_ref().len(), 4);
|
||||
for (disk_index, endpoint) in (0_i32..).zip(pool.endpoints.as_ref()) {
|
||||
assert_eq!(endpoint.pool_idx, pool_index);
|
||||
assert_eq!(endpoint.set_idx, 0);
|
||||
assert_eq!(endpoint.disk_idx, disk_index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_local_endpoint_host_fails_closed_for_invalid_context_or_zero_match() {
|
||||
let args = vec![
|
||||
|
||||
@@ -55,6 +55,8 @@ mod set_disk;
|
||||
mod storage_api_contracts;
|
||||
mod store;
|
||||
|
||||
pub use store::PoolMetaWriteGateStatus;
|
||||
|
||||
// pub mod checksum;
|
||||
mod event;
|
||||
|
||||
|
||||
@@ -20,9 +20,10 @@ use chrono::Utc;
|
||||
use jiff::Timestamp;
|
||||
use rustfs_heal_contracts::heal_channel::DriveState;
|
||||
use rustfs_io_metrics::internode_metrics::global_internode_metrics;
|
||||
use rustfs_io_metrics::s3_http_metrics::s3_http_metrics_snapshot;
|
||||
use rustfs_madmin::metrics::{
|
||||
DiskIOStats, DiskMetric, LastMinute as MadminLastMinute, NetDevLine, NetMetrics, RPCMetrics, RealtimeMetrics,
|
||||
ScannerCheckpointReport as MadminScannerCheckpointReport,
|
||||
DiskIOStats, DiskMetric, HttpMetrics, HttpRequestMetric, LastMinute as MadminLastMinute, NetDevLine, NetMetrics, RPCMetrics,
|
||||
RealtimeMetrics, ScannerCheckpointReport as MadminScannerCheckpointReport,
|
||||
ScannerLifecycleExpirySnapshot as MadminScannerLifecycleExpirySnapshot,
|
||||
ScannerLifecycleTransitionSnapshot as MadminScannerLifecycleTransitionSnapshot,
|
||||
ScannerMaintenanceControlSnapshot as MadminScannerMaintenanceControlSnapshot,
|
||||
@@ -61,9 +62,10 @@ impl MetricType {
|
||||
pub const MEM: MetricType = MetricType(1 << 6);
|
||||
pub const CPU: MetricType = MetricType(1 << 7);
|
||||
pub const RPC: MetricType = MetricType(1 << 8);
|
||||
pub const HTTP: MetricType = MetricType(1 << 9);
|
||||
|
||||
// MetricsAll must be last.
|
||||
pub const ALL: MetricType = MetricType((1 << 9) - 1);
|
||||
pub const ALL: MetricType = MetricType((1 << 10) - 1);
|
||||
|
||||
pub fn new(t: u32) -> Self {
|
||||
Self(t)
|
||||
@@ -410,6 +412,21 @@ pub async fn collect_local_metrics(types: MetricType, opts: &CollectMetricsOpts)
|
||||
by_host_name = local_node_name;
|
||||
}
|
||||
|
||||
if types.contains(&MetricType::HTTP) {
|
||||
real_time_metrics.aggregated.http = Some(HttpMetrics {
|
||||
collected_at: Timestamp::now(),
|
||||
requests: s3_http_metrics_snapshot()
|
||||
.into_iter()
|
||||
.map(|series| HttpRequestMetric {
|
||||
method: series.method.to_string(),
|
||||
operation: series.operation.to_string(),
|
||||
outcome: series.outcome.to_string(),
|
||||
total: series.total,
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
|
||||
if types.contains(&MetricType::DISK) {
|
||||
debug!("start get disk metrics");
|
||||
let mut aggr = DiskMetric {
|
||||
@@ -585,11 +602,47 @@ mod test {
|
||||
assert!(t.contains(&MetricType::MEM));
|
||||
assert!(t.contains(&MetricType::CPU));
|
||||
assert!(t.contains(&MetricType::RPC));
|
||||
assert!(t.contains(&MetricType::HTTP));
|
||||
|
||||
let disk = MetricType::new(1 << 1);
|
||||
assert!(disk.contains(&MetricType::DISK));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collect_local_metrics_reports_the_same_http_outcome_counters() {
|
||||
let mut request = rustfs_io_metrics::s3_http_metrics::S3HttpRequestGuard::new("PUT");
|
||||
request.response(503);
|
||||
drop(request);
|
||||
let snapshot = s3_http_metrics_snapshot();
|
||||
let realtime = collect_local_metrics(MetricType::HTTP, &CollectMetricsOpts::default()).await;
|
||||
let http = realtime.aggregated.http.as_ref().expect("HTTP selection must report support");
|
||||
assert_eq!(http.requests.len(), snapshot.len());
|
||||
for (actual, expected) in http.requests.iter().zip(&snapshot) {
|
||||
assert_eq!(actual.method, expected.method);
|
||||
assert_eq!(actual.operation, expected.operation);
|
||||
assert_eq!(actual.outcome, expected.outcome);
|
||||
assert_eq!(actual.total, expected.total);
|
||||
}
|
||||
assert_eq!(realtime.by_host.len(), 1);
|
||||
assert_eq!(
|
||||
realtime
|
||||
.by_host
|
||||
.values()
|
||||
.next()
|
||||
.expect("local host")
|
||||
.http
|
||||
.as_ref()
|
||||
.expect("host HTTP")
|
||||
.requests,
|
||||
http.requests
|
||||
);
|
||||
let encoded = rmp_serde::to_vec_named(&realtime).expect("RPC metric map");
|
||||
let decoded: RealtimeMetrics = rmp_serde::from_slice(&encoded).expect("RPC metric roundtrip");
|
||||
assert_eq!(decoded.aggregated.http.expect("HTTP field survives RPC").requests, http.requests);
|
||||
let excluded = collect_local_metrics(MetricType::NET, &CollectMetricsOpts::default()).await;
|
||||
assert!(excluded.aggregated.http.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collect_local_metrics_reports_internode_net_and_rpc() {
|
||||
let metrics = global_internode_metrics();
|
||||
|
||||
@@ -31,7 +31,7 @@ use lazy_static::lazy_static;
|
||||
use rustfs_madmin::health::{Cpus, MemInfo, OsInfo, Partitions, ProcInfo, SysConfig, SysErrors, SysServices};
|
||||
use rustfs_madmin::metrics::RealtimeMetrics;
|
||||
use rustfs_madmin::net::NetInfo;
|
||||
use rustfs_madmin::{ItemState, ServerProperties, StorageInfo};
|
||||
use rustfs_madmin::{ItemState, ServerProperties, StorageInfo, StorageInfoObservation, StorageInfoProbeStatus};
|
||||
use rustfs_utils::XHost;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, hash_map::DefaultHasher};
|
||||
@@ -53,6 +53,7 @@ const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||
const LOG_SUBSYSTEM_NOTIFICATION: &str = "notification";
|
||||
const EVENT_NOTIFICATION_PEER_PROPAGATION: &str = "notification_peer_propagation";
|
||||
const EVENT_NOTIFICATION_CAPABILITY_PROBE: &str = "notification_capability_probe";
|
||||
const EVENT_STORAGE_INFO_PROBE: &str = "storage_info_probe";
|
||||
const SCANNER_ACTIVITY_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const TIER_DAILY_STATS_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const TIER_CONFIG_RELOAD_RETRY_BASE: Duration = Duration::from_millis(100);
|
||||
@@ -140,6 +141,8 @@ pub struct ScannerPublicationLeaseGrant {
|
||||
/// Cached result from the last successful admin call to a peer.
|
||||
struct PeerAdminCache {
|
||||
last_storage_info: Option<StorageInfo>,
|
||||
/// Wall time is for operators; the monotonic clock bounds cache reuse.
|
||||
last_storage_success: Option<(SystemTime, Instant)>,
|
||||
last_server_info: Option<ServerProperties>,
|
||||
storage_failures: u32,
|
||||
server_failures: u32,
|
||||
@@ -163,6 +166,7 @@ impl PeerAdminCache {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
last_storage_info: None,
|
||||
last_storage_success: None,
|
||||
last_server_info: None,
|
||||
storage_failures: 0,
|
||||
server_failures: 0,
|
||||
@@ -175,6 +179,9 @@ impl PeerAdminCache {
|
||||
/// failure: rather than reporting a stale `online`, the member falls through to
|
||||
/// the live unknown/degraded/offline classification (rustfs/backlog#1049 P2).
|
||||
const SERVER_INFO_CACHE_MAX_AGE: Duration = Duration::from_secs(60);
|
||||
// Diagnostic inventory may bridge a short probe interruption, but never more
|
||||
// than one minute. Failed probes are marked unknown even within this budget.
|
||||
const STORAGE_INFO_CACHE_MAX_AGE: Duration = Duration::from_secs(60);
|
||||
|
||||
lazy_static! {
|
||||
pub static ref GLOBAL_NOTIFICATION_SYS: OnceLock<Arc<NotificationSys>> = OnceLock::new();
|
||||
@@ -1906,6 +1913,7 @@ impl NotificationSys {
|
||||
for (idx, client) in self.peer_clients.iter().enumerate() {
|
||||
let endpoints = endpoints.clone();
|
||||
let cache = self.peer_admin_caches.get(idx);
|
||||
let topology_host = self.peer_topology_hosts.get(idx);
|
||||
futures.push(async move {
|
||||
if let Some(client) = client {
|
||||
let host = client.host.to_string();
|
||||
@@ -1916,32 +1924,46 @@ impl NotificationSys {
|
||||
normalize_and_cache_peer_storage_info(cache, &host, &mut info);
|
||||
Some(info)
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
warn!("peer {} storage_info failed: {}", host, err);
|
||||
handle_peer_failure(cache, &host, &endpoints)
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("peer {} storage_info timed out after {:?}", host, peer_timeout);
|
||||
handle_peer_failure(cache, &host, &endpoints)
|
||||
}
|
||||
Ok(Err(err)) => handle_peer_failure(cache, &host, &endpoints, &err),
|
||||
Err(_) => handle_peer_failure(cache, &host, &endpoints, &Error::Timeout),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
topology_host.and_then(|host| {
|
||||
handle_peer_failure(
|
||||
cache,
|
||||
host,
|
||||
&endpoints,
|
||||
&Error::RemoteClientUnavailable("storage inventory client is unavailable".to_string()),
|
||||
)
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut replies = join_all(futures).await;
|
||||
|
||||
replies.push(Some(StorageAdminApi::local_storage_info(api).await));
|
||||
let mut local = StorageAdminApi::local_storage_info(api).await;
|
||||
local.observations = vec![storage_info_observation(
|
||||
&runtime_sources::local_node_name().await,
|
||||
StorageInfoProbeStatus::Succeeded,
|
||||
false,
|
||||
Some((SystemTime::now(), Instant::now())),
|
||||
)];
|
||||
replies.push(Some(local));
|
||||
|
||||
let mut disks = Vec::new();
|
||||
let mut observations = Vec::new();
|
||||
for info in replies.into_iter().flatten() {
|
||||
disks.extend(info.disks);
|
||||
observations.extend(info.observations);
|
||||
}
|
||||
|
||||
let backend = StorageAdminApi::backend_info(api).await;
|
||||
rustfs_madmin::StorageInfo { disks, backend }
|
||||
rustfs_madmin::StorageInfo {
|
||||
disks,
|
||||
backend,
|
||||
observations,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn server_info(&self) -> Vec<ServerProperties> {
|
||||
@@ -3339,56 +3361,80 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a peer failure for storage_info: return cached data if available,
|
||||
/// or mark offline only after consecutive failures exceed the threshold.
|
||||
fn storage_info_observation(
|
||||
host: &str,
|
||||
status: StorageInfoProbeStatus,
|
||||
cached: bool,
|
||||
last_success: Option<(SystemTime, Instant)>,
|
||||
) -> StorageInfoObservation {
|
||||
StorageInfoObservation {
|
||||
endpoint: host.to_string(),
|
||||
status,
|
||||
cached,
|
||||
last_success_unix_millis: last_success
|
||||
.and_then(|(wall, _)| wall.duration_since(SystemTime::UNIX_EPOCH).ok())
|
||||
.and_then(|age| u64::try_from(age.as_millis()).ok()),
|
||||
snapshot_age_seconds: last_success.map(|(_, monotonic)| monotonic.elapsed().as_secs()),
|
||||
error_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// An admin RPC failure is missing evidence, not evidence of failed drives.
|
||||
/// Preserve bounded historical inventory without presenting its states as live.
|
||||
fn handle_peer_failure(
|
||||
cache: Option<&Mutex<PeerAdminCache>>,
|
||||
host: &str,
|
||||
endpoints: &EndpointServerPools,
|
||||
error: &Error,
|
||||
) -> Option<StorageInfo> {
|
||||
let cache = cache?;
|
||||
|
||||
let mut c = match cache.lock() {
|
||||
Ok(cache) => cache,
|
||||
Err(poisoned) => {
|
||||
warn!("peer {host} storage_info cache mutex poisoned");
|
||||
poisoned.into_inner()
|
||||
}
|
||||
};
|
||||
c.storage_failures += 1;
|
||||
|
||||
if let Some(ref cached) = c.last_storage_info
|
||||
&& c.storage_failures < CONSECUTIVE_FAILURE_THRESHOLD
|
||||
{
|
||||
debug!(
|
||||
event = "peer_probe_failure",
|
||||
peer = host,
|
||||
probe = "storage_info",
|
||||
consecutive_failures = c.storage_failures,
|
||||
threshold = CONSECUTIVE_FAILURE_THRESHOLD,
|
||||
"peer storage_info probe failed; returning cached state until the offline threshold is reached"
|
||||
);
|
||||
return Some(cached.clone());
|
||||
}
|
||||
|
||||
if c.storage_failures >= CONSECUTIVE_FAILURE_THRESHOLD {
|
||||
if c.storage_failures == CONSECUTIVE_FAILURE_THRESHOLD {
|
||||
let mut cache = cache.map(|cache| cache.lock().unwrap_or_else(|poisoned| poisoned.into_inner()));
|
||||
let last_success = cache.as_ref().and_then(|cache| cache.last_storage_success);
|
||||
let historical = cache
|
||||
.as_ref()
|
||||
.filter(|_| last_success.is_some_and(|(_, when)| when.elapsed() < STORAGE_INFO_CACHE_MAX_AGE))
|
||||
.and_then(|cache| cache.last_storage_info.clone());
|
||||
let cached = historical.is_some();
|
||||
let mut info = historical.unwrap_or_else(|| StorageInfo {
|
||||
disks: synthesized_disks(host, endpoints, ItemState::Unknown),
|
||||
..Default::default()
|
||||
});
|
||||
if let Some(cache) = &mut cache {
|
||||
cache.storage_failures = cache.storage_failures.saturating_add(1);
|
||||
if cache.storage_failures == 1 {
|
||||
warn!(
|
||||
event = "peer_marked_offline",
|
||||
event = EVENT_STORAGE_INFO_PROBE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_NOTIFICATION,
|
||||
state = "failed",
|
||||
peer = host,
|
||||
probe = "storage_info",
|
||||
consecutive_failures = c.storage_failures,
|
||||
threshold = CONSECUTIVE_FAILURE_THRESHOLD,
|
||||
"reporting peer disks offline after consecutive storage_info failures"
|
||||
error_code = ?error.code(),
|
||||
cached,
|
||||
"Storage inventory probe failed; current drive health is unknown"
|
||||
);
|
||||
}
|
||||
return Some(StorageInfo {
|
||||
disks: synthesized_disks(host, endpoints, ItemState::Offline),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
for disk in &mut info.disks {
|
||||
disk.state = rustfs_madmin::ITEM_UNKNOWN.to_string();
|
||||
disk.runtime_state = Some(rustfs_madmin::ITEM_UNKNOWN.to_string());
|
||||
disk.offline_duration_seconds = None;
|
||||
disk.capacity_observation_source = Some(if cached { "snapshot" } else { "missing" }.to_string());
|
||||
disk.capacity_observation_age_seconds = if cached {
|
||||
disk.capacity_observation_age_seconds
|
||||
.zip(last_success)
|
||||
.map(|(age, (_, when))| age.saturating_add(when.elapsed().as_secs()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
disk.local = false;
|
||||
}
|
||||
info.observations = vec![storage_info_observation(
|
||||
host,
|
||||
StorageInfoProbeStatus::Failed,
|
||||
cached,
|
||||
last_success,
|
||||
)];
|
||||
info.observations[0].error_code = Some(format!("{:?}", error.code()));
|
||||
Some(info)
|
||||
}
|
||||
|
||||
fn normalize_and_cache_peer_storage_info(cache: Option<&Mutex<PeerAdminCache>>, host: &str, info: &mut StorageInfo) {
|
||||
@@ -3397,6 +3443,15 @@ fn normalize_and_cache_peer_storage_info(cache: Option<&Mutex<PeerAdminCache>>,
|
||||
for disk in &mut info.disks {
|
||||
disk.local = false;
|
||||
}
|
||||
let last_success = (SystemTime::now(), Instant::now());
|
||||
// The aggregator owns probe provenance, including when an older peer
|
||||
// returns no observation or a peer sends its own observation fields.
|
||||
info.observations = vec![storage_info_observation(
|
||||
host,
|
||||
StorageInfoProbeStatus::Succeeded,
|
||||
false,
|
||||
Some(last_success),
|
||||
)];
|
||||
|
||||
let Some(cache) = cache else {
|
||||
return;
|
||||
@@ -3409,16 +3464,20 @@ fn normalize_and_cache_peer_storage_info(cache: Option<&Mutex<PeerAdminCache>>,
|
||||
poisoned.into_inner()
|
||||
}
|
||||
};
|
||||
if c.storage_failures >= CONSECUTIVE_FAILURE_THRESHOLD {
|
||||
if c.storage_failures > 0 {
|
||||
info!(
|
||||
event = "peer_recovered_online",
|
||||
event = EVENT_STORAGE_INFO_PROBE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_NOTIFICATION,
|
||||
state = "succeeded",
|
||||
peer = host,
|
||||
probe = "storage_info",
|
||||
consecutive_failures = c.storage_failures,
|
||||
"peer storage_info probe succeeded again; peer disks reported online"
|
||||
"Storage inventory probe recovered"
|
||||
);
|
||||
}
|
||||
c.last_storage_info = Some(info.clone());
|
||||
c.last_storage_success = Some(last_success);
|
||||
c.storage_failures = 0;
|
||||
}
|
||||
|
||||
@@ -4892,6 +4951,7 @@ mod tests {
|
||||
server_failures: 1,
|
||||
storage_failures: 0,
|
||||
last_storage_info: None,
|
||||
last_storage_success: None,
|
||||
});
|
||||
let cache_b = Mutex::new(PeerAdminCache {
|
||||
last_server_info: Some(build_props("cached-b")),
|
||||
@@ -4899,6 +4959,7 @@ mod tests {
|
||||
server_failures: 1,
|
||||
storage_failures: 0,
|
||||
last_storage_info: None,
|
||||
last_storage_success: None,
|
||||
});
|
||||
let caches = [cache_a, cache_b];
|
||||
let endpoints = EndpointServerPools::from(Vec::new());
|
||||
@@ -5297,13 +5358,78 @@ mod tests {
|
||||
|
||||
// --- Tests for handle_peer_failure / handle_server_info_failure caching ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn storage_info_preserves_failed_members_when_no_rpc_client_exists() {
|
||||
#[derive(Debug)]
|
||||
struct LocalInventory;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl StorageAdminApi for LocalInventory {
|
||||
type BackendInfo = rustfs_madmin::BackendInfo;
|
||||
type StorageInfo = StorageInfo;
|
||||
type Disk = ();
|
||||
type Error = Error;
|
||||
|
||||
async fn backend_info(&self) -> Self::BackendInfo {
|
||||
Self::BackendInfo::default()
|
||||
}
|
||||
|
||||
async fn storage_info(&self) -> StorageInfo {
|
||||
panic!("aggregation must query local inventory only")
|
||||
}
|
||||
|
||||
async fn local_storage_info(&self) -> StorageInfo {
|
||||
StorageInfo::default()
|
||||
}
|
||||
|
||||
async fn disk_set_inventory(
|
||||
&self,
|
||||
_: crate::storage_api_contracts::admin::DiskSetSelector,
|
||||
) -> Result<Vec<Option<Self::Disk>>> {
|
||||
panic!("admin probe must not access the data plane")
|
||||
}
|
||||
|
||||
fn set_drive_counts(&self) -> Vec<usize> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
let sys = NotificationSys {
|
||||
peer_clients: vec![None],
|
||||
all_peer_clients: vec![None, None],
|
||||
peer_topology_hosts: vec!["peer-unavailable".to_string()],
|
||||
peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())],
|
||||
tier_config_reload_workers: Default::default(),
|
||||
};
|
||||
let info = sys.storage_info(&LocalInventory).await;
|
||||
let peer = info
|
||||
.observations
|
||||
.iter()
|
||||
.find(|observation| observation.endpoint == "peer-unavailable")
|
||||
.expect("failed topology member remains visible");
|
||||
assert_eq!(peer.status, StorageInfoProbeStatus::Failed);
|
||||
assert!(!peer.cached);
|
||||
assert_eq!(peer.error_code.as_deref(), Some("RemoteClientUnavailable"));
|
||||
assert!(
|
||||
info.observations
|
||||
.iter()
|
||||
.any(|observation| observation.status == StorageInfoProbeStatus::Succeeded)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_peer_failure_first_failure_returns_none_when_no_cache() {
|
||||
fn handle_peer_failure_first_failure_reports_unknown_inventory_without_cache() {
|
||||
let cache = Mutex::new(PeerAdminCache::new());
|
||||
let endpoints = EndpointServerPools::default();
|
||||
|
||||
let result = handle_peer_failure(Some(&cache), "peer-1", &endpoints);
|
||||
assert!(result.is_none());
|
||||
let result = handle_peer_failure(Some(&cache), "peer-1", &endpoints, &Error::Timeout);
|
||||
let info = result.expect("failed peer must remain visible without cached disks");
|
||||
assert!(info.disks.is_empty());
|
||||
assert_eq!(info.observations[0].status, StorageInfoProbeStatus::Failed);
|
||||
assert!(!info.observations[0].cached);
|
||||
assert_eq!(info.observations[0].last_success_unix_millis, None);
|
||||
assert_eq!(info.observations[0].snapshot_age_seconds, None);
|
||||
assert_eq!(info.observations[0].error_code.as_deref(), Some("Timeout"));
|
||||
assert_eq!(cache.lock().unwrap().storage_failures, 1);
|
||||
}
|
||||
|
||||
@@ -5320,6 +5446,7 @@ mod tests {
|
||||
|
||||
let cache = Mutex::new(PeerAdminCache {
|
||||
last_storage_info: Some(cached_info),
|
||||
last_storage_success: Some((SystemTime::now(), Instant::now())),
|
||||
last_server_info: None,
|
||||
storage_failures: 0,
|
||||
server_failures: 0,
|
||||
@@ -5327,11 +5454,17 @@ mod tests {
|
||||
});
|
||||
let endpoints = EndpointServerPools::default();
|
||||
|
||||
// First failure: should return cached data
|
||||
let result = handle_peer_failure(Some(&cache), "peer-1", &endpoints);
|
||||
// Historical inventory is available, but its health is not live.
|
||||
let result = handle_peer_failure(Some(&cache), "peer-1", &endpoints, &Error::Timeout);
|
||||
let info = result.unwrap();
|
||||
assert_eq!(info.disks.len(), 1);
|
||||
assert_eq!(info.disks[0].state, "ok");
|
||||
assert_eq!(info.disks[0].state, "unknown");
|
||||
assert_eq!(info.disks[0].runtime_state.as_deref(), Some("unknown"));
|
||||
assert_eq!(info.disks[0].capacity_observation_source.as_deref(), Some("snapshot"));
|
||||
assert_eq!(info.disks[0].capacity_observation_age_seconds, None);
|
||||
assert!(info.observations[0].cached);
|
||||
assert_eq!(info.observations[0].status, StorageInfoProbeStatus::Failed);
|
||||
assert!(info.observations[0].last_success_unix_millis.is_some());
|
||||
assert_eq!(cache.lock().unwrap().storage_failures, 1);
|
||||
}
|
||||
|
||||
@@ -5377,13 +5510,13 @@ mod tests {
|
||||
);
|
||||
drop(cached);
|
||||
|
||||
let degraded = handle_peer_failure(Some(&cache), "peer-1", &EndpointServerPools::default())
|
||||
let degraded = handle_peer_failure(Some(&cache), "peer-1", &EndpointServerPools::default(), &Error::Timeout)
|
||||
.expect("first peer failure must return the cached snapshot");
|
||||
assert!(degraded.disks.iter().all(|disk| !disk.local));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_peer_failure_returns_offline_after_threshold_exceeded() {
|
||||
fn handle_peer_failure_cache_age_does_not_depend_on_poll_count() {
|
||||
let cached_info = StorageInfo {
|
||||
disks: vec![rustfs_madmin::Disk {
|
||||
endpoint: "disk-0".to_string(),
|
||||
@@ -5395,6 +5528,7 @@ mod tests {
|
||||
|
||||
let cache = Mutex::new(PeerAdminCache {
|
||||
last_storage_info: Some(cached_info),
|
||||
last_storage_success: Some((SystemTime::now(), Instant::now())),
|
||||
last_server_info: None,
|
||||
storage_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1,
|
||||
server_failures: 0,
|
||||
@@ -5402,10 +5536,31 @@ mod tests {
|
||||
});
|
||||
let endpoints = EndpointServerPools::default();
|
||||
|
||||
// This failure pushes us to the threshold => offline
|
||||
let result = handle_peer_failure(Some(&cache), "peer-1", &endpoints);
|
||||
assert!(result.is_some());
|
||||
assert_eq!(cache.lock().unwrap().storage_failures, CONSECUTIVE_FAILURE_THRESHOLD);
|
||||
for _ in 0..10 {
|
||||
let info = handle_peer_failure(Some(&cache), "peer-1", &endpoints, &Error::Timeout).expect("failed probe");
|
||||
assert_eq!(info.disks.len(), 1);
|
||||
assert_eq!(info.disks[0].state, "unknown");
|
||||
assert!(info.observations[0].cached);
|
||||
}
|
||||
cache.lock().expect("age cache").last_storage_success =
|
||||
Some((SystemTime::now() - Duration::from_secs(61), Instant::now() - Duration::from_secs(61)));
|
||||
let info = handle_peer_failure(Some(&cache), "peer-1", &endpoints, &Error::Timeout).expect("expired probe");
|
||||
assert!(!info.observations[0].cached);
|
||||
assert!(info.observations[0].snapshot_age_seconds.expect("known last success") >= 61);
|
||||
assert!(info.disks.is_empty(), "expired inventory must not be reused");
|
||||
|
||||
let mut recovered = StorageInfo {
|
||||
disks: vec![rustfs_madmin::Disk {
|
||||
state: "ok".into(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
normalize_and_cache_peer_storage_info(Some(&cache), "peer-1", &mut recovered);
|
||||
assert_eq!(recovered.disks[0].state, "ok");
|
||||
assert_eq!(recovered.observations[0].status, StorageInfoProbeStatus::Succeeded);
|
||||
assert!(!recovered.observations[0].cached);
|
||||
assert_eq!(cache.lock().expect("recovered cache").storage_failures, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5418,6 +5573,7 @@ mod tests {
|
||||
|
||||
let cache = Mutex::new(PeerAdminCache {
|
||||
last_storage_info: None,
|
||||
last_storage_success: None,
|
||||
last_server_info: Some(cached_props),
|
||||
storage_failures: 0,
|
||||
server_failures: 0,
|
||||
@@ -5448,6 +5604,7 @@ mod tests {
|
||||
|
||||
let cache = Mutex::new(PeerAdminCache {
|
||||
last_storage_info: None,
|
||||
last_storage_success: None,
|
||||
last_server_info: Some(cached_props),
|
||||
storage_failures: 0,
|
||||
server_failures: 0,
|
||||
@@ -5575,6 +5732,7 @@ mod tests {
|
||||
|
||||
let cache = Mutex::new(PeerAdminCache {
|
||||
last_storage_info: None,
|
||||
last_storage_success: None,
|
||||
last_server_info: Some(cached_props),
|
||||
storage_failures: 0,
|
||||
server_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1,
|
||||
@@ -5594,6 +5752,7 @@ mod tests {
|
||||
// the real per-drive health), not offline (rustfs/backlog#1049 P0-B).
|
||||
let cache = Mutex::new(PeerAdminCache {
|
||||
last_storage_info: None,
|
||||
last_storage_success: None,
|
||||
last_server_info: None,
|
||||
storage_failures: 0,
|
||||
server_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1,
|
||||
@@ -5622,6 +5781,7 @@ mod tests {
|
||||
// this is a genuine offline, degraded must not mask it.
|
||||
let cache = Mutex::new(PeerAdminCache {
|
||||
last_storage_info: None,
|
||||
last_storage_success: None,
|
||||
last_server_info: None,
|
||||
storage_failures: 0,
|
||||
server_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1,
|
||||
@@ -5645,6 +5805,7 @@ mod tests {
|
||||
fn success_resets_failure_counters_independently() {
|
||||
let cache = Mutex::new(PeerAdminCache {
|
||||
last_storage_info: None,
|
||||
last_storage_success: None,
|
||||
last_server_info: None,
|
||||
storage_failures: 2,
|
||||
server_failures: 2,
|
||||
@@ -5666,6 +5827,7 @@ mod tests {
|
||||
fn storage_failures_do_not_affect_server_failures() {
|
||||
let cache = Mutex::new(PeerAdminCache {
|
||||
last_storage_info: Some(StorageInfo::default()),
|
||||
last_storage_success: None,
|
||||
last_server_info: Some(ServerProperties {
|
||||
endpoint: "peer-1".to_string(),
|
||||
state: "online".to_string(),
|
||||
@@ -5677,7 +5839,7 @@ mod tests {
|
||||
});
|
||||
let endpoints = EndpointServerPools::default();
|
||||
|
||||
let storage_result = handle_peer_failure(Some(&cache), "peer-1", &endpoints);
|
||||
let storage_result = handle_peer_failure(Some(&cache), "peer-1", &endpoints, &Error::Timeout);
|
||||
assert!(storage_result.is_some());
|
||||
|
||||
let server_result = handle_server_info_failure(Some(&cache), "peer-1", &endpoints, None);
|
||||
@@ -5700,8 +5862,10 @@ mod tests {
|
||||
panic!("poison server cache mutex");
|
||||
});
|
||||
|
||||
let storage_result = handle_peer_failure(Some(&storage_cache), "peer-1", &endpoints);
|
||||
assert!(storage_result.is_none());
|
||||
let storage_result = handle_peer_failure(Some(&storage_cache), "peer-1", &endpoints, &Error::Timeout);
|
||||
let storage = storage_result.expect("poisoned cache must still report the failed peer");
|
||||
assert_eq!(storage.observations[0].status, StorageInfoProbeStatus::Failed);
|
||||
assert!(!storage.observations[0].cached);
|
||||
|
||||
let server_result = handle_server_info_failure(Some(&server_cache), "peer-1", &endpoints, None);
|
||||
assert_eq!(server_result.endpoint, "peer-1");
|
||||
@@ -5712,6 +5876,7 @@ mod tests {
|
||||
fn poisoned_admin_cache_recovers_on_success_and_resets_failures() {
|
||||
let storage_cache = Mutex::new(PeerAdminCache {
|
||||
last_storage_info: None,
|
||||
last_storage_success: None,
|
||||
last_server_info: None,
|
||||
storage_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1,
|
||||
server_failures: 0,
|
||||
@@ -5719,6 +5884,7 @@ mod tests {
|
||||
});
|
||||
let server_cache = Mutex::new(PeerAdminCache {
|
||||
last_storage_info: None,
|
||||
last_storage_success: None,
|
||||
last_server_info: None,
|
||||
storage_failures: 0,
|
||||
server_failures: CONSECUTIVE_FAILURE_THRESHOLD - 1,
|
||||
@@ -5757,9 +5923,11 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let storage_result = handle_peer_failure(Some(&storage_cache), "peer-1", &endpoints);
|
||||
let storage_result = handle_peer_failure(Some(&storage_cache), "peer-1", &endpoints, &Error::Timeout);
|
||||
assert!(storage_result.is_some());
|
||||
assert_eq!(storage_result.unwrap().disks[0].state, "ok");
|
||||
let storage = storage_result.expect("failed probe after recovery");
|
||||
assert_eq!(storage.disks[0].state, "unknown");
|
||||
assert!(storage.observations[0].cached);
|
||||
|
||||
let server_result = handle_server_info_failure(Some(&server_cache), "peer-1", &endpoints, None);
|
||||
assert_eq!(server_result.state, "online");
|
||||
|
||||
@@ -300,11 +300,13 @@ pub(super) fn ensure_rebalance_worker_active(meta: Option<&RebalanceMeta>, expec
|
||||
let Some(meta) = meta else {
|
||||
return Err(rebalance_metadata_not_initialized_error(stage));
|
||||
};
|
||||
if meta.stopped_at.is_some()
|
||||
|| meta
|
||||
.cancel
|
||||
.as_ref()
|
||||
.is_some_and(tokio_util::sync::CancellationToken::is_cancelled)
|
||||
if meta.stopped_at.is_some() || meta.stop_requested {
|
||||
return Err(Error::OperationCanceled);
|
||||
}
|
||||
if meta
|
||||
.cancel
|
||||
.as_ref()
|
||||
.is_some_and(tokio_util::sync::CancellationToken::is_cancelled)
|
||||
|| !is_rebalance_conflicting_with_decommission(meta)
|
||||
{
|
||||
return Err(Error::other(format!("inactive rebalance worker rejected during {stage}: {expected_id}")));
|
||||
@@ -629,6 +631,7 @@ impl ECStore {
|
||||
if let Some(meta) = rebalance_meta.as_mut()
|
||||
&& is_rebalance_conflicting_with_decommission(meta)
|
||||
{
|
||||
meta.stop_requested = true;
|
||||
meta.cancel
|
||||
.get_or_insert_with(tokio_util::sync::CancellationToken::new)
|
||||
.cancel();
|
||||
@@ -643,12 +646,13 @@ impl ECStore {
|
||||
let Some(meta) = rebalance_meta.as_mut() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !is_rebalance_conflicting_with_decommission(meta) {
|
||||
if meta.stopped_at.is_some() || (!is_rebalance_conflicting_with_decommission(meta) && !meta.stop_requested) {
|
||||
return Ok(None);
|
||||
}
|
||||
if meta.id.is_empty() {
|
||||
return Err(Error::other("active rebalance metadata has no activation id"));
|
||||
}
|
||||
meta.stop_requested = true;
|
||||
meta.cancel
|
||||
.get_or_insert_with(tokio_util::sync::CancellationToken::new)
|
||||
.cancel();
|
||||
@@ -673,7 +677,13 @@ impl ECStore {
|
||||
let movement_changed = rebalance_movement_snapshot_changed(self.rebalance_meta.read().await.as_ref(), &meta);
|
||||
{
|
||||
let mut rebalance_meta = self.rebalance_meta.write().await;
|
||||
|
||||
if let Some(current) = rebalance_meta.as_ref()
|
||||
&& current.id == meta.id
|
||||
{
|
||||
meta.cancel = current.cancel.clone();
|
||||
meta.activation_gate = Arc::clone(¤t.activation_gate);
|
||||
meta.stop_requested = current.stop_requested;
|
||||
}
|
||||
*rebalance_meta = Some(meta);
|
||||
|
||||
drop(rebalance_meta);
|
||||
@@ -1188,11 +1198,12 @@ impl ECStore {
|
||||
let meta = rebalance_meta
|
||||
.as_mut()
|
||||
.ok_or_else(|| rebalance_metadata_not_initialized_error("cancel rebalance admission"))?;
|
||||
if meta.stopped_at.is_some() || !is_rebalance_conflicting_with_decommission(meta) {
|
||||
if meta.stopped_at.is_some() || (!is_rebalance_conflicting_with_decommission(meta) && !meta.stop_requested) {
|
||||
return Err(Error::other(format!(
|
||||
"inactive rebalance rejected while cancelling admission: {expected_id}"
|
||||
)));
|
||||
}
|
||||
meta.stop_requested = true;
|
||||
meta.cancel
|
||||
.get_or_insert_with(tokio_util::sync::CancellationToken::new)
|
||||
.cancel();
|
||||
@@ -1213,6 +1224,7 @@ impl ECStore {
|
||||
ensure_rebalance_run_id(rebalance_meta.as_ref(), expected_id, "stop rebalance")?;
|
||||
}
|
||||
rebalance_meta.as_mut().map(|meta| {
|
||||
meta.stop_requested |= is_rebalance_conflicting_with_decommission(meta);
|
||||
let cancel = meta.cancel.get_or_insert_with(tokio_util::sync::CancellationToken::new);
|
||||
cancel.cancel();
|
||||
Arc::clone(&meta.activation_gate)
|
||||
@@ -1377,6 +1389,79 @@ mod tests {
|
||||
probe.wait_until_attempted().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebalance_stop_classification_checks_identity_and_explicit_intent() {
|
||||
let mut meta = RebalanceMeta {
|
||||
id: "current".to_string(),
|
||||
cancel: Some(tokio_util::sync::CancellationToken::new()),
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Started,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
ensure_rebalance_worker_active(Some(&meta), "current", "test").expect("active worker");
|
||||
meta.cancel.as_ref().unwrap().cancel();
|
||||
assert!(
|
||||
!matches!(
|
||||
ensure_rebalance_worker_active(Some(&meta), "current", "test"),
|
||||
Err(Error::OperationCanceled)
|
||||
),
|
||||
"a sibling failure is not an operator stop"
|
||||
);
|
||||
meta.stop_requested = true;
|
||||
assert!(matches!(
|
||||
ensure_rebalance_worker_active(Some(&meta), "current", "test"),
|
||||
Err(Error::OperationCanceled)
|
||||
));
|
||||
assert!(
|
||||
!matches!(ensure_rebalance_worker_active(Some(&meta), "old", "test"), Err(Error::OperationCanceled)),
|
||||
"stale identity remains a failure even during stop"
|
||||
);
|
||||
assert!(!matches!(
|
||||
ensure_rebalance_worker_active(None, "current", "test"),
|
||||
Err(Error::OperationCanceled)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rebalance_stop_intent_does_not_survive_replacement_run_reload() {
|
||||
let (_temp_dirs, store) = crate::services::rebalance::test_store_with_persisted_rebalance_meta(RebalanceMeta {
|
||||
id: "replacement".to_string(),
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Completed,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let previous_gate = {
|
||||
let mut meta = store.rebalance_meta.write().await;
|
||||
let meta = meta.as_mut().unwrap();
|
||||
meta.id = "previous".to_string();
|
||||
meta.stop_requested = true;
|
||||
let cancel = tokio_util::sync::CancellationToken::new();
|
||||
cancel.cancel();
|
||||
meta.cancel = Some(cancel);
|
||||
Arc::clone(&meta.activation_gate)
|
||||
};
|
||||
store.load_rebalance_meta().await.expect("reload replacement run");
|
||||
let meta = store.rebalance_meta.read().await;
|
||||
let meta = meta.as_ref().unwrap();
|
||||
assert_eq!(meta.id, "replacement");
|
||||
assert!(!meta.stop_requested);
|
||||
assert!(meta.cancel.is_none());
|
||||
assert!(!Arc::ptr_eq(&previous_gate, &meta.activation_gate));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_rebalance_admission_is_id_checked_and_idempotent() {
|
||||
let rebalance_id = "rebalance-admission-current";
|
||||
@@ -1415,6 +1500,59 @@ mod tests {
|
||||
.await
|
||||
.expect("retrying admission cancellation should be idempotent");
|
||||
assert!(cancel.is_cancelled());
|
||||
let err = store
|
||||
.update_pool_stats_batch_for_rebalance(0, "bucket".to_string(), &[&FileInfo::default()], rebalance_id)
|
||||
.await
|
||||
.expect_err("stop racing with a final stats update must cancel that update");
|
||||
assert!(matches!(err, Error::OperationCanceled), "operator stop lost its cancellation type: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepare_rebalance_stop_preserves_intent_when_worker_stops_before_reload() {
|
||||
let id = "stop-worker-before-reload";
|
||||
let cancel = tokio_util::sync::CancellationToken::new();
|
||||
let (_temp_dirs, store) = crate::services::rebalance::test_store_with_persisted_rebalance_meta(RebalanceMeta {
|
||||
id: id.to_string(),
|
||||
cancel: Some(cancel.clone()),
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Stopped,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let gate = {
|
||||
let mut meta = store.rebalance_meta.write().await;
|
||||
let meta = meta.as_mut().expect("local rebalance metadata");
|
||||
meta.pool_stats[0].info.status = RebalStatus::Started;
|
||||
Arc::clone(&meta.activation_gate)
|
||||
};
|
||||
assert_eq!(
|
||||
store.prepare_rebalance_stop().await.expect("prepare the same run stop"),
|
||||
Some(id.to_string())
|
||||
);
|
||||
{
|
||||
let meta = store.rebalance_meta.read().await;
|
||||
let meta = meta.as_ref().expect("reloaded stop target");
|
||||
assert!(Arc::ptr_eq(&gate, &meta.activation_gate), "reload must retain the drained run's gate");
|
||||
assert!(meta.cancel.as_ref().is_some_and(|token| token.is_cancelled()));
|
||||
}
|
||||
store
|
||||
.stop_rebalance_for_id(Some(id))
|
||||
.await
|
||||
.expect("finish the stop after the worker's terminal event");
|
||||
store
|
||||
.load_rebalance_meta()
|
||||
.await
|
||||
.expect("reload the acknowledged durable stop");
|
||||
let meta = store.rebalance_meta.read().await;
|
||||
let meta = meta.as_ref().expect("durable stopped metadata");
|
||||
assert!(meta.stopped_at.is_some(), "a successful stop must retain its durable timestamp");
|
||||
assert_eq!(meta.pool_stats[0].info.status, RebalStatus::Stopped);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2031,7 +2169,10 @@ mod tests {
|
||||
let err = acquire_persisted_rebalance_run_guard(set_disks, active.id.as_str(), "cross-node stale snapshot")
|
||||
.await
|
||||
.expect_err("persisted stop must fence a node that missed stop propagation");
|
||||
assert!(err.to_string().contains("inactive rebalance worker rejected"));
|
||||
assert!(
|
||||
matches!(err, Error::OperationCanceled),
|
||||
"a durable remote stop cancels the same run: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -19,11 +19,11 @@ use super::meta::{
|
||||
use super::migration::{RebalanceMigrationBackend, migrate_entry_version};
|
||||
use super::worker::{
|
||||
RebalanceEntryCleanupResult, RebalanceEntryTask, load_rebalance_bucket_configs, rebalance_max_attempts,
|
||||
resolve_rebalance_bucket_error, resolve_rebalance_entry_cleanup_delete_result, resolve_rebalance_file_info_versions_result,
|
||||
resolve_rebalance_migrate_result_error, resolve_rebalance_stats_update_result, resolve_rebalance_worker_result,
|
||||
run_rebalance_listing_with_retry, should_cleanup_rebalance_source_entry, should_count_rebalance_version_complete,
|
||||
should_defer_rebalance_entry_failure, should_skip_rebalance_delete_marker, wait_rebalance_entry_tasks,
|
||||
with_rebalance_entry_context,
|
||||
record_rebalance_error, resolve_rebalance_bucket_error, resolve_rebalance_entry_cleanup_delete_result,
|
||||
resolve_rebalance_file_info_versions_result, resolve_rebalance_migrate_result_error, resolve_rebalance_stats_update_result,
|
||||
resolve_rebalance_worker_result, run_rebalance_listing_with_retry, should_cleanup_rebalance_source_entry,
|
||||
should_count_rebalance_version_complete, should_defer_rebalance_entry_failure, should_skip_rebalance_delete_marker,
|
||||
wait_rebalance_entry_tasks, with_rebalance_entry_context,
|
||||
};
|
||||
use super::{
|
||||
EVENT_REBALANCE_BUCKET, EVENT_REBALANCE_ENTRY, EVENT_REBALANCE_STATE, LOG_COMPONENT_ECSTORE, LOG_SUBSYSTEM_REBALANCE,
|
||||
@@ -676,10 +676,8 @@ impl ECStore {
|
||||
}
|
||||
error!("rebalance_entry: data movement admission failed: {err}");
|
||||
let mut first_err = entry_error.lock().await;
|
||||
if first_err.is_none() {
|
||||
*first_err = Some(err);
|
||||
callback_rx.cancel();
|
||||
}
|
||||
record_rebalance_error(&mut first_err, err);
|
||||
callback_rx.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -721,10 +719,8 @@ impl ECStore {
|
||||
if let Err(err) = &result {
|
||||
error!("rebalance_entry: rebalance entry failed: {err}");
|
||||
let mut first_err = entry_error.lock().await;
|
||||
if first_err.is_none() {
|
||||
*first_err = Some(err.clone());
|
||||
callback_rx.cancel();
|
||||
}
|
||||
record_rebalance_error(&mut first_err, err.clone());
|
||||
callback_rx.cancel();
|
||||
}
|
||||
debug!(
|
||||
event = EVENT_REBALANCE_ENTRY,
|
||||
@@ -793,10 +789,7 @@ impl ECStore {
|
||||
deferred_error = Some(last_error);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) if worker_error.is_none() => {
|
||||
worker_error = Some(err);
|
||||
}
|
||||
Err(_) => {}
|
||||
Err(err) => record_rebalance_error(&mut worker_error, err),
|
||||
}
|
||||
}
|
||||
let entry_error = entry_error.lock().await.clone();
|
||||
|
||||
@@ -643,16 +643,12 @@ pub(super) fn should_skip_start_rebalance(cancel_attached: bool, in_progress: bo
|
||||
cancel_attached && in_progress
|
||||
}
|
||||
|
||||
pub(super) fn is_rebalance_stopped_terminal_event(terminal_event: &RebalanceTerminalEvent) -> bool {
|
||||
matches!(terminal_event, RebalanceTerminalEvent::Stopped { .. })
|
||||
}
|
||||
|
||||
pub(super) fn should_preserve_rebalance_stopped_state(
|
||||
meta_stopped: bool,
|
||||
status: RebalStatus,
|
||||
terminal_event: &RebalanceTerminalEvent,
|
||||
) -> bool {
|
||||
(meta_stopped || status == RebalStatus::Stopped) && !is_rebalance_stopped_terminal_event(terminal_event)
|
||||
(meta_stopped || status == RebalStatus::Stopped) && matches!(terminal_event, RebalanceTerminalEvent::Completed { .. })
|
||||
}
|
||||
|
||||
pub(super) fn resolve_rebalance_participants(pool_stats: &[RebalanceStats], pool_count: usize) -> Vec<bool> {
|
||||
@@ -920,7 +916,7 @@ pub(super) fn clear_rebalance_cancel_token(meta: Option<&mut RebalanceMeta>) ->
|
||||
|
||||
pub(super) fn stop_rebalance_state(meta: &mut RebalanceMeta, now: OffsetDateTime) {
|
||||
clear_rebalance_cancel_token(Some(meta));
|
||||
if meta.stopped_at.is_none() && is_rebalance_in_progress(meta) {
|
||||
if meta.stopped_at.is_none() && (meta.stop_requested || is_rebalance_in_progress(meta)) {
|
||||
apply_stopped_at(meta, now);
|
||||
} else if meta.stopped_at.is_some() {
|
||||
mark_started_rebalance_pools_stopping(meta);
|
||||
|
||||
@@ -19,14 +19,14 @@ use super::meta::{
|
||||
complete_rebalance_pools_at_goal, complete_rebalance_pools_with_empty_queue, defer_bucket_in_rebalance_queue,
|
||||
ensure_rebalance_not_decommissioning, ensure_valid_rebalance_pool_index, first_rebalance_bucket,
|
||||
has_deferred_rebalance_error, is_rebalance_actively_running, is_rebalance_conflicting_with_decommission,
|
||||
is_rebalance_in_progress, is_rebalance_meta_replaceable_for_new_id, is_rebalance_stopped_terminal_event,
|
||||
mark_rebalance_bucket_done, merge_rebalance_bucket_lists, merge_rebalance_meta, next_rebal_bucket_from_stat,
|
||||
percent_free_ratio, rebalance_goal_reached, rebalance_meta_load_no_data_error, rebalance_meta_load_unknown_format_error,
|
||||
rebalance_meta_load_unknown_version_error, rebalance_requires_worker_activation, record_rebalance_cleanup_warning_in_meta,
|
||||
remove_rebalanced_buckets_from_queue, resolve_next_rebalance_bucket, resolve_rebalance_participants,
|
||||
should_accept_rebalance_stats_update, should_ignore_rebalance_data_usage_cache, should_pool_participate,
|
||||
should_preserve_rebalance_stopped_state, should_skip_start_rebalance, stop_rebalance_meta_snapshot, stop_rebalance_state,
|
||||
take_bucket_from_rebalance_queue, validate_init_rebalance_state, validate_start_rebalance_state,
|
||||
is_rebalance_in_progress, is_rebalance_meta_replaceable_for_new_id, mark_rebalance_bucket_done, merge_rebalance_bucket_lists,
|
||||
merge_rebalance_meta, next_rebal_bucket_from_stat, percent_free_ratio, rebalance_goal_reached,
|
||||
rebalance_meta_load_no_data_error, rebalance_meta_load_unknown_format_error, rebalance_meta_load_unknown_version_error,
|
||||
rebalance_requires_worker_activation, record_rebalance_cleanup_warning_in_meta, remove_rebalanced_buckets_from_queue,
|
||||
resolve_next_rebalance_bucket, resolve_rebalance_participants, should_accept_rebalance_stats_update,
|
||||
should_ignore_rebalance_data_usage_cache, should_pool_participate, should_preserve_rebalance_stopped_state,
|
||||
should_skip_start_rebalance, stop_rebalance_meta_snapshot, stop_rebalance_state, take_bucket_from_rebalance_queue,
|
||||
validate_init_rebalance_state, validate_start_rebalance_state,
|
||||
};
|
||||
use super::migration::{
|
||||
MigrationBackend, MigrationVersionResult, migrate_entry_version, migrate_entry_version_with_retry_wait,
|
||||
@@ -1676,6 +1676,30 @@ fn test_resolve_rebalance_stats_update_result_passthrough() {
|
||||
assert!(resolve_rebalance_stats_update_result(Ok(()), 0, "bucket", "object").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rebalance_stop_preserves_cancellation_through_entry_context() {
|
||||
let err = resolve_rebalance_stats_update_result(Err(Error::OperationCanceled), 0, "bucket", "object")
|
||||
.expect_err("canceled stats update");
|
||||
let err = with_rebalance_entry_context("stats", "bucket", "object", err);
|
||||
assert!(matches!(err, Error::OperationCanceled));
|
||||
assert!(matches!(
|
||||
classify_rebalance_terminal_event(Some(Err(err)), OffsetDateTime::now_utc()),
|
||||
RebalanceTerminalEvent::Stopped { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rebalance_stop_does_not_hide_later_entry_failure() {
|
||||
let tasks = Arc::new(tokio::sync::Mutex::new(vec![
|
||||
tokio::spawn(async { Err(Error::OperationCanceled) }),
|
||||
tokio::spawn(async { Err(Error::ErasureWriteQuorum) }),
|
||||
]));
|
||||
let err = wait_rebalance_entry_tasks(0, tasks)
|
||||
.await
|
||||
.expect_err("entry I/O failure must survive sibling cancellation");
|
||||
assert!(matches!(err, Error::ErasureWriteQuorum));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_rebalance_stats_update_result_wraps_error_context() {
|
||||
let err = resolve_rebalance_stats_update_result(Err(Error::SlowDown), 2, "bucket-a", "obj.txt")
|
||||
@@ -2365,9 +2389,9 @@ fn test_resolve_rebalance_terminal_error_wraps_signal_failure_context() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_rebalance_bucket_error_prefers_entry_error() {
|
||||
fn test_resolve_rebalance_bucket_error_prefers_real_failure_over_entry_cancellation() {
|
||||
let err = resolve_rebalance_bucket_error(Some(Error::OperationCanceled), Some(Error::SlowDown)).unwrap_err();
|
||||
assert!(matches!(err, Error::OperationCanceled));
|
||||
assert!(matches!(err, Error::SlowDown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2512,19 +2536,6 @@ fn test_apply_rebalance_terminal_event_stopped_clears_error() {
|
||||
assert_eq!(last_error, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_rebalance_stopped_terminal_event_only_matches_stopped_variant() {
|
||||
let stopped = RebalanceTerminalEvent::Stopped {
|
||||
msg: "stopped".to_string(),
|
||||
};
|
||||
let completed = RebalanceTerminalEvent::Completed {
|
||||
msg: "completed".to_string(),
|
||||
};
|
||||
|
||||
assert!(is_rebalance_stopped_terminal_event(&stopped));
|
||||
assert!(!is_rebalance_stopped_terminal_event(&completed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_preserve_rebalance_stopped_state_when_meta_marked_stopped() {
|
||||
let event = RebalanceTerminalEvent::Completed {
|
||||
@@ -2535,13 +2546,14 @@ fn test_should_preserve_rebalance_stopped_state_when_meta_marked_stopped() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_preserve_rebalance_stopped_state_when_pool_already_stopped() {
|
||||
fn test_rebalance_stop_does_not_hide_real_terminal_failure() {
|
||||
let event = RebalanceTerminalEvent::Failed {
|
||||
msg: "failed".to_string(),
|
||||
last_error: "boom".to_string(),
|
||||
};
|
||||
|
||||
assert!(should_preserve_rebalance_stopped_state(false, RebalStatus::Stopped, &event));
|
||||
assert!(!should_preserve_rebalance_stopped_state(false, RebalStatus::Stopped, &event));
|
||||
assert!(!should_preserve_rebalance_stopped_state(true, RebalStatus::Started, &event));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2716,6 +2728,32 @@ async fn test_start_rebalance_for_id_rejects_stopped_metadata() {
|
||||
assert!(err.to_string().contains("was stopped before start"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rebalance_stop_intent_blocks_activation_before_durable_timestamp() {
|
||||
let mut meta = RebalanceMeta {
|
||||
id: "stopping".to_string(),
|
||||
stop_requested: true,
|
||||
pool_stats: vec![RebalanceStats {
|
||||
participating: true,
|
||||
buckets: vec!["pending".to_string()],
|
||||
info: RebalanceInfo {
|
||||
status: RebalStatus::Started,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let outcome = commit_local_rebalance_worker_activation(&mut meta, "stopping", CancellationToken::new())
|
||||
.expect("stop must prevent activation without a new error");
|
||||
assert_eq!(outcome, RebalanceLocalActivationOutcome::NotStartedTerminal);
|
||||
assert!(meta.cancel.is_none());
|
||||
assert!(meta.stopped_at.is_none());
|
||||
let bytes = rmp_serde::to_vec_named(&meta).expect("encode legacy-compatible metadata");
|
||||
let reloaded: RebalanceMeta = rmp_serde::from_slice(&bytes).expect("decode metadata");
|
||||
assert!(!reloaded.stop_requested, "operator intent is local, not a new persisted field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stopped_activation_state_prevents_worker_token_commit() {
|
||||
let mut meta = RebalanceMeta {
|
||||
|
||||
@@ -57,7 +57,7 @@ pub(super) fn commit_local_rebalance_worker_activation(
|
||||
meta.id
|
||||
)));
|
||||
}
|
||||
if meta.stopped_at.is_some() || !is_rebalance_in_progress(meta) {
|
||||
if meta.stopped_at.is_some() || meta.stop_requested || !is_rebalance_in_progress(meta) {
|
||||
return Ok(RebalanceLocalActivationOutcome::NotStartedTerminal);
|
||||
}
|
||||
meta.cancel = Some(cancel);
|
||||
|
||||
@@ -143,6 +143,10 @@ pub struct DiskStat {
|
||||
pub struct RebalanceMeta {
|
||||
#[serde(skip)]
|
||||
pub cancel: Option<CancellationToken>, // To be invoked on rebalance-stop
|
||||
/// Local operator intent, scoped to this run ID; a worker failure also cancels
|
||||
/// `cancel`, so the token alone cannot identify an administrative stop.
|
||||
#[serde(skip)]
|
||||
pub stop_requested: bool,
|
||||
#[serde(skip)]
|
||||
pub activation_gate: std::sync::Arc<tokio::sync::RwLock<()>>,
|
||||
#[serde(skip)]
|
||||
|
||||
@@ -38,6 +38,17 @@ pub(super) fn resolve_rebalance_worker_result<T>(
|
||||
|
||||
pub(super) type RebalanceEntryTask = tokio::task::JoinHandle<Result<RebalanceEntryOutcome>>;
|
||||
|
||||
/// Preserve the first real failure even when another task observes cancellation
|
||||
/// first. Cancellation is an outcome only when no entry or worker failed.
|
||||
pub(super) fn record_rebalance_error(first_error: &mut Option<Error>, err: Error) {
|
||||
if first_error
|
||||
.as_ref()
|
||||
.is_none_or(|first| is_err_operation_canceled(first) && !is_err_operation_canceled(&err))
|
||||
{
|
||||
*first_error = Some(err);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) enum RebalanceEntryCleanupResult {
|
||||
Completed { warning: Option<String> },
|
||||
@@ -65,16 +76,12 @@ pub(super) async fn wait_rebalance_entry_tasks(
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
error!("rebalance entry task failed for set {}: {}", set_idx, err);
|
||||
if first_error.is_none() {
|
||||
first_error = Some(err);
|
||||
}
|
||||
record_rebalance_error(&mut first_error, err);
|
||||
}
|
||||
Err(err) => {
|
||||
let err = Error::other(format!("rebalance entry task join error for set {set_idx}: {err}"));
|
||||
error!("{}", err);
|
||||
if first_error.is_none() {
|
||||
first_error = Some(err);
|
||||
}
|
||||
record_rebalance_error(&mut first_error, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,6 +142,9 @@ pub(super) fn resolve_rebalance_stats_update_result(
|
||||
object_name: &str,
|
||||
) -> Result<()> {
|
||||
result.map_err(|err| {
|
||||
if is_err_operation_canceled(&err) {
|
||||
return err;
|
||||
}
|
||||
Error::other(format!(
|
||||
"rebalance stats update failed for pool {pool_idx} bucket {bucket} object {object_name}: {err}"
|
||||
))
|
||||
@@ -214,16 +224,11 @@ pub(super) fn resolve_rebalance_terminal_error(primary_err: Error, signal_result
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_rebalance_bucket_error(entry_error: Option<Error>, worker_error: Option<Error>) -> Result<()> {
|
||||
if let Some(err) = entry_error {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
pub(super) fn resolve_rebalance_bucket_error(mut entry_error: Option<Error>, worker_error: Option<Error>) -> Result<()> {
|
||||
if let Some(err) = worker_error {
|
||||
return Err(err);
|
||||
record_rebalance_error(&mut entry_error, err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
entry_error.map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
pub(super) fn resolve_rebalance_bucket_result(
|
||||
@@ -362,6 +367,9 @@ pub(super) fn ensure_rebalance_listing_disks_available(has_disks: bool, bucket:
|
||||
}
|
||||
|
||||
pub(super) fn with_rebalance_entry_context(stage: &str, bucket: &str, object_name: &str, err: Error) -> Error {
|
||||
if is_err_operation_canceled(&err) {
|
||||
return err;
|
||||
}
|
||||
Error::other(format!("rebalance entry {stage} failed for {bucket}/{object_name}: {err}"))
|
||||
}
|
||||
|
||||
|
||||
@@ -6650,6 +6650,7 @@ async fn get_storage_info(disks: &[Option<DiskStore>], eps: &[Endpoint]) -> rust
|
||||
total_sets,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
pub async fn stat_all_dirs(disks: &[Option<DiskStore>], bucket: &str, prefix: &str) -> Vec<Option<DiskError>> {
|
||||
|
||||
@@ -2353,11 +2353,22 @@ mod tests {
|
||||
.await
|
||||
.expect("quorum boundary heal should return a mapped result");
|
||||
*store.pools[0].disk_set[0].disks.write().await = original_quorum_disks;
|
||||
let quorum_err = quorum_err
|
||||
.as_ref()
|
||||
.expect("heal must fail closed when capacity admission cannot verify pool metadata");
|
||||
let quorum_failure = quorum_err
|
||||
.pool_metadata_failure()
|
||||
.expect("capacity admission failure should preserve typed pool metadata context");
|
||||
assert_eq!(
|
||||
quorum_failure.kind,
|
||||
crate::error::PoolMetadataFailure::ReadUnavailable,
|
||||
"read-only capacity admission failure must remain retryable"
|
||||
);
|
||||
assert_eq!(quorum_failure.operation, "target capacity admission failed");
|
||||
assert_eq!(quorum_failure.phase, "pool_read");
|
||||
assert!(
|
||||
quorum_err.as_ref().is_some_and(|err| err
|
||||
.to_string()
|
||||
.contains("pool metadata writes remain blocked after a recovery-required replica state")),
|
||||
"heal must fail closed when capacity admission cannot verify pool metadata, got {quorum_err:?}"
|
||||
store.pool_meta_writes_ready().await,
|
||||
"read-only capacity admission failure must not latch the pool metadata writer"
|
||||
);
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
|
||||
use super::*;
|
||||
use crate::core::pools::{
|
||||
PoolMetaBootstrapAuthority, PoolMetaReplicaState, PoolMetaWriteState, load_pool_meta_identity_observing,
|
||||
local_decommission_queue_prefix, persist_pool_meta_identity_for_startup, pool_meta_has_active_decommission,
|
||||
PoolMetaBootstrapAuthority, PoolMetaReplicaState, PoolMetaWriteState, local_decommission_queue_prefix,
|
||||
persist_pool_meta_identity_for_startup, pool_meta_has_active_decommission,
|
||||
};
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
@@ -103,7 +103,10 @@ const REBALANCE_INITIAL_RESUME_DELAY: Duration = Duration::from_secs(10);
|
||||
const REBALANCE_RESUME_RETRY_DELAY: Duration = Duration::from_secs(10);
|
||||
|
||||
fn should_retry_format_load(err: &Error) -> bool {
|
||||
!matches!(err, Error::CorruptedFormat)
|
||||
!matches!(
|
||||
err,
|
||||
Error::CorruptedFormat | Error::UnsupportedSnsdExpansion { .. } | Error::PoolTopologyMismatch { .. }
|
||||
)
|
||||
}
|
||||
|
||||
fn should_auto_start_rebalance_after_init(decommission_running: bool, rebalance_resume_required: bool) -> bool {
|
||||
@@ -150,14 +153,11 @@ async fn load_pool_meta_for_startup<S>(
|
||||
where
|
||||
S: EcstoreObjectIO,
|
||||
{
|
||||
load_pool_meta_identity_observing(pools.clone(), write_state)
|
||||
.await
|
||||
.map_err(|err| Error::other(format!("store init failed during load_pool_meta_identity: {err}")))?;
|
||||
let mut meta = PoolMeta::default();
|
||||
let replica_state = meta
|
||||
.load_no_lock_from_replicas_observing(pools, write_state)
|
||||
.load_for_startup_observing(pools, write_state)
|
||||
.await
|
||||
.map_err(|err| Error::other(format!("store init failed during load_pool_meta: {err}")))?;
|
||||
.map_err(|err| Error::other_with_context("store init failed during load_pool_meta", err))?;
|
||||
write_state.observe_replicas(replica_state);
|
||||
write_state
|
||||
.ensure_missing_metadata_can_initialize()
|
||||
@@ -766,6 +766,33 @@ impl ECStore {
|
||||
});
|
||||
}
|
||||
|
||||
let recovery_store = self.clone();
|
||||
let recovery_rx = rx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut delay = std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = recovery_rx.cancelled() => return,
|
||||
_ = tokio::time::sleep(delay) => {}
|
||||
}
|
||||
let result = tokio::select! {
|
||||
_ = recovery_rx.cancelled() => return,
|
||||
result = tokio::time::timeout(std::time::Duration::from_secs(30), recovery_store.recover_pool_meta_transaction()) => result,
|
||||
};
|
||||
delay = match result {
|
||||
Ok(Ok(_)) => std::time::Duration::from_secs(5),
|
||||
failure => {
|
||||
let error = match failure {
|
||||
Ok(Err(error)) => error,
|
||||
_ => Error::Timeout,
|
||||
};
|
||||
recovery_store.record_pool_meta_recovery_failure(error);
|
||||
(delay * 2).min(std::time::Duration::from_secs(60))
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
runtime_sources::init_bucket_monitor_for_current_endpoints();
|
||||
crate::bucket::bucket_target_sys::BucketTargetSys::get().start_heartbeat();
|
||||
|
||||
@@ -1784,6 +1811,33 @@ mod tests {
|
||||
assert!(should_retry_format_load(&StorageError::FirstDiskWait));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_retry_format_load_rejects_permanent_topology_errors() {
|
||||
for error in [
|
||||
StorageError::UnsupportedSnsdExpansion { configured_drives: 4 },
|
||||
StorageError::PoolTopologyMismatch {
|
||||
stored_drives: 4,
|
||||
stored_set_drive_count: 4,
|
||||
configured_drives: 8,
|
||||
configured_set_drive_count: 8,
|
||||
},
|
||||
] {
|
||||
assert!(!should_retry_format_load(&error), "topology errors require operator action: {error}");
|
||||
}
|
||||
for error in [
|
||||
StorageError::DiskNotFound,
|
||||
StorageError::Timeout,
|
||||
StorageError::RemoteNotInitialized,
|
||||
StorageError::NotFirstDisk,
|
||||
StorageError::other(std::io::Error::from(std::io::ErrorKind::ConnectionRefused)),
|
||||
] {
|
||||
assert!(
|
||||
should_retry_format_load(&error),
|
||||
"transient failures retain their existing retry path: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_auto_start_rebalance_after_init_allows_active_rebalance_without_decommission() {
|
||||
assert!(should_auto_start_rebalance_after_init(false, true));
|
||||
@@ -2463,6 +2517,106 @@ mod tests {
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn pool_metadata_preflight_recovery_preserves_single_and_multi_pool_public_mutations() {
|
||||
for layout in [vec![4], vec![4, 4]] {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let (_ctx, store, shutdown) =
|
||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "pool-meta-retry", &layout)).await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||
let bucket = format!("pool-meta-retry-{}", Uuid::new_v4());
|
||||
store.make_bucket(&bucket, &MakeBucketOptions::default()).await.unwrap();
|
||||
let mut saved_disks = Vec::new();
|
||||
for set in &store.pools[0].disk_set {
|
||||
let mut disks = set.disks.write().await;
|
||||
let count = disks.len();
|
||||
saved_disks.push((set.clone(), std::mem::replace(&mut *disks, vec![None; count])));
|
||||
}
|
||||
let indices = (0..layout.len()).collect::<Vec<_>>();
|
||||
let err = store.save_current_pool_meta_for_test(&indices).await.unwrap_err();
|
||||
assert_eq!(
|
||||
err.pool_metadata_failure().unwrap().kind,
|
||||
crate::error::PoolMetadataFailure::ReadUnavailable
|
||||
);
|
||||
for (set, disks) in saved_disks {
|
||||
*set.disks.write().await = disks;
|
||||
}
|
||||
store.save_current_pool_meta_for_test(&indices).await.unwrap();
|
||||
assert!(store.pool_meta_writes_ready().await);
|
||||
|
||||
let payload = b"pool metadata recovery payload".to_vec();
|
||||
store
|
||||
.put_object(&bucket, "put", &mut PutObjReader::from_vec(payload.clone()), &ObjectOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut reader = store
|
||||
.get_object_reader(&bucket, "put", None, HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut actual = Vec::new();
|
||||
reader.stream.read_to_end(&mut actual).await.unwrap();
|
||||
assert_eq!(actual, payload);
|
||||
drop(reader);
|
||||
store.delete_object(&bucket, "put", ObjectOptions::default()).await.unwrap();
|
||||
assert!(crate::error::is_err_object_not_found(
|
||||
&store
|
||||
.get_object_info(&bucket, "put", &ObjectOptions::default())
|
||||
.await
|
||||
.unwrap_err()
|
||||
));
|
||||
|
||||
let upload = store
|
||||
.new_multipart_upload(&bucket, "multipart", &ObjectOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let part = store
|
||||
.put_object_part(
|
||||
&bucket,
|
||||
"multipart",
|
||||
&upload.upload_id,
|
||||
1,
|
||||
&mut PutObjReader::from_vec(payload.clone()),
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.clone()
|
||||
.complete_multipart_upload(
|
||||
&bucket,
|
||||
"multipart",
|
||||
&upload.upload_id,
|
||||
vec![crate::storage_api_contracts::multipart::CompletePart {
|
||||
part_num: part.part_num,
|
||||
etag: part.etag,
|
||||
..Default::default()
|
||||
}],
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut reader = store
|
||||
.get_object_reader(&bucket, "multipart", None, HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
actual.clear();
|
||||
reader.stream.read_to_end(&mut actual).await.unwrap();
|
||||
assert_eq!(actual, payload);
|
||||
drop(reader);
|
||||
let upload = store
|
||||
.new_multipart_upload(&bucket, "abort", &ObjectOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.abort_multipart_upload(&bucket, "abort", &upload.upload_id, &ObjectOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.pool_meta_writes_ready().await);
|
||||
shutdown.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
|
||||
@@ -109,6 +109,21 @@ pub(crate) async fn connect_load_init_formats_with_instance_ctx(
|
||||
let fresh_bootstrap_proven = should_init_erasure_disks(&errs);
|
||||
let formats_present = formats.iter().flatten().count();
|
||||
let mut format_quorum = (formats_present > 0).then(|| select_format_erasure_in_quorum(&formats, 0));
|
||||
// A resized pool may never reach quorum under its new endpoint count.
|
||||
// Diagnose a valid, unambiguous stored layout before migration or waiting.
|
||||
// A healthy quorum still takes precedence over foreign minority formats;
|
||||
// conflicting or malformed observations retain their existing error path.
|
||||
if format_quorum.as_ref().is_some_and(Result::is_err)
|
||||
&& let Some(reference) = formats.iter().flatten().next()
|
||||
&& formats.iter().flatten().all(|format| {
|
||||
format.shared_identity() == reference.shared_identity()
|
||||
&& reference.erasure.sets.iter().flatten().any(|id| *id == format.erasure.this)
|
||||
})
|
||||
&& let Err(err @ (Error::UnsupportedSnsdExpansion { .. } | Error::PoolTopologyMismatch { .. })) =
|
||||
check_format_erasure_value_for_topology(reference, formats.len(), set_drive_count)
|
||||
{
|
||||
return Err(err);
|
||||
}
|
||||
if format_quorum.as_ref().is_none_or(Result::is_err)
|
||||
&& errs.iter().any(|error| {
|
||||
matches!(
|
||||
@@ -661,15 +676,18 @@ fn check_format_erasure_value_for_topology(format: &FormatV3, format_count: usiz
|
||||
.len()
|
||||
.checked_mul(set_drive_count_in_format)
|
||||
.ok_or_else(|| Error::other("erasure set drive count overflow"))?;
|
||||
if format_count != format_drive_count {
|
||||
return Err(Error::other(format!(
|
||||
"formats length for erasure.sets does not match: got {format_count}, expected {format_drive_count}"
|
||||
)));
|
||||
if format_drive_count == 1 && format_count > 1 {
|
||||
return Err(Error::UnsupportedSnsdExpansion {
|
||||
configured_drives: format_count,
|
||||
});
|
||||
}
|
||||
if set_drive_count_in_format != set_drive_count {
|
||||
return Err(Error::other(format!(
|
||||
"erasure set length for set_drive_count does not match: got {set_drive_count_in_format}, expected {set_drive_count}"
|
||||
)));
|
||||
if format_count != format_drive_count || set_drive_count_in_format != set_drive_count {
|
||||
return Err(Error::PoolTopologyMismatch {
|
||||
stored_drives: format_drive_count,
|
||||
stored_set_drive_count: set_drive_count_in_format,
|
||||
configured_drives: format_count,
|
||||
configured_set_drive_count: set_drive_count,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -877,6 +895,10 @@ mod tests {
|
||||
use serial_test::serial;
|
||||
|
||||
async fn local_disks(count: usize) -> (tempfile::TempDir, Vec<Option<DiskStore>>) {
|
||||
local_disks_with_set_width(count, count).await
|
||||
}
|
||||
|
||||
async fn local_disks_with_set_width(count: usize, set_width: usize) -> (tempfile::TempDir, Vec<Option<DiskStore>>) {
|
||||
let temp_dir = tempfile::tempdir().expect("temporary disk root should be created");
|
||||
let mut endpoints = Vec::with_capacity(count);
|
||||
for disk_index in 0..count {
|
||||
@@ -887,8 +909,8 @@ mod tests {
|
||||
let mut endpoint =
|
||||
Endpoint::try_from(path.to_str().expect("temporary disk path should be UTF-8")).expect("endpoint should parse");
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(disk_index);
|
||||
endpoint.set_set_index(disk_index / set_width);
|
||||
endpoint.set_disk_index(disk_index % set_width);
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
|
||||
@@ -912,6 +934,21 @@ mod tests {
|
||||
(temp_dir, disks)
|
||||
}
|
||||
|
||||
async fn format_bytes(disks: &[Option<DiskStore>]) -> Vec<Option<Vec<u8>>> {
|
||||
let mut snapshots = Vec::with_capacity(disks.len());
|
||||
for disk in disks {
|
||||
let disk = disk.as_ref().expect("snapshot disk should exist");
|
||||
// Inspect bytes even when the disk wrapper rejects a format whose
|
||||
// stored slot differs from the attempted new endpoint geometry.
|
||||
match tokio::fs::read(disk.path().join(RUSTFS_META_BUCKET).join(FORMAT_CONFIG_FILE)).await {
|
||||
Ok(data) => snapshots.push(Some(data)),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => snapshots.push(None),
|
||||
Err(err) => panic!("format snapshot failed: {err}"),
|
||||
}
|
||||
}
|
||||
snapshots
|
||||
}
|
||||
|
||||
async fn write_legacy_format(disk: &Option<DiskStore>, format: &FormatV3) {
|
||||
write_legacy_bytes(disk, bytes::Bytes::from(format.to_json().expect("legacy format should serialize"))).await;
|
||||
}
|
||||
@@ -1116,6 +1153,212 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_drive_format_rejects_in_place_expansion_without_writes() {
|
||||
for configured_drives in [2, 4] {
|
||||
for first_disk in [false, true] {
|
||||
let (_temp_dir, mut disks) = local_disks(configured_drives).await;
|
||||
let mut original = FormatV3::new(1, 1);
|
||||
original.erasure.this = original.erasure.sets[0][0];
|
||||
save_format_file(&disks[0], &Some(original))
|
||||
.await
|
||||
.expect("SNSD format should be written");
|
||||
let before = format_bytes(&disks).await;
|
||||
|
||||
let err = connect_load_init_formats(first_disk, &mut disks, 1, configured_drives, None)
|
||||
.await
|
||||
.expect_err("an existing SNSD deployment cannot expand in place");
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("SNSD"), "expected a single-drive expansion error: {message}");
|
||||
assert!(message.contains("migrate data through S3"), "expected actionable guidance: {message}");
|
||||
assert_eq!(format_bytes(&disks).await, before, "neither old nor new formats may be written");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_pool_rejects_drive_count_or_set_width_changes_without_writes() {
|
||||
for (stored_sets, stored_width, configured_sets, configured_width) in
|
||||
[(1, 4, 1, 6), (1, 4, 1, 8), (1, 4, 1, 2), (1, 4, 2, 2), (2, 2, 1, 4)]
|
||||
{
|
||||
for first_disk in [false, true] {
|
||||
let (_temp_dir, mut disks) =
|
||||
local_disks_with_set_width(configured_sets * configured_width, configured_width).await;
|
||||
let original = FormatV3::new(stored_sets, stored_width);
|
||||
for (disk, disk_id) in disks.iter().zip(original.erasure.sets.iter().flatten()) {
|
||||
let mut format = original.clone();
|
||||
format.erasure.this = *disk_id;
|
||||
save_format_file(disk, &Some(format))
|
||||
.await
|
||||
.expect("existing format should be written");
|
||||
}
|
||||
let before = format_bytes(&disks).await;
|
||||
|
||||
let err = connect_load_init_formats(first_disk, &mut disks, configured_sets, configured_width, None)
|
||||
.await
|
||||
.expect_err("an existing pool's geometry is immutable");
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("pool topology mismatch"), "expected a topology error: {message}");
|
||||
assert!(
|
||||
message.contains(&format!("stored 4 drives with {stored_width} drives per erasure set")),
|
||||
"expected stored geometry: {message}"
|
||||
);
|
||||
assert!(message.contains("append a new pool"), "expected expansion guidance: {message}");
|
||||
assert_eq!(format_bytes(&disks).await, before, "rejection must not rewrite any format");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subquorum_existing_layout_with_missing_drives_is_not_expansion() {
|
||||
let (_temp_dir, mut disks) = local_disks(1).await;
|
||||
let mut original = FormatV3::new(1, 4);
|
||||
original.erasure.this = original.erasure.sets[0][0];
|
||||
save_format_file(&disks[0], &Some(original))
|
||||
.await
|
||||
.expect("existing format should be written");
|
||||
disks.extend([None, None, None]);
|
||||
|
||||
for first_disk in [false, true] {
|
||||
assert!(matches!(
|
||||
connect_load_init_formats(first_disk, &mut disks, 1, 4, None).await,
|
||||
Err(Error::ErasureReadQuorum)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn conflicting_layouts_without_quorum_are_not_expansion_proof() {
|
||||
let (_temp_dir, mut disks) = local_disks(2).await;
|
||||
for (index, (disk, width)) in disks.iter().zip([4, 2]).enumerate() {
|
||||
let mut format = FormatV3::new(1, width);
|
||||
format.erasure.this = format.erasure.sets[0][index];
|
||||
save_format_file(disk, &Some(format))
|
||||
.await
|
||||
.expect("existing format should be written");
|
||||
}
|
||||
disks.extend([None, None]);
|
||||
|
||||
let result = connect_load_init_formats(true, &mut disks, 1, 4, None).await;
|
||||
assert!(matches!(result, Err(Error::ErasureReadQuorum)), "conflicting layout result: {result:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_format_quorum_ignores_single_drive_outlier() {
|
||||
let (_temp_dir, mut disks) = local_disks(3).await;
|
||||
let majority = FormatV3::new(1, 3);
|
||||
for (index, disk) in disks.iter().enumerate() {
|
||||
// Slot zero lets the SNSD outlier pass the disk wrapper's own
|
||||
// slot check, so quorum selection must exclude the parsed format.
|
||||
let mut format = if index == 0 { FormatV3::new(1, 1) } else { majority.clone() };
|
||||
format.erasure.this = format.erasure.sets[0][index];
|
||||
save_format_file(disk, &Some(format))
|
||||
.await
|
||||
.expect("existing format should be written");
|
||||
}
|
||||
|
||||
let loaded = connect_load_init_formats(true, &mut disks, 1, 3, None)
|
||||
.await
|
||||
.expect("a foreign SNSD outlier must not block a healthy majority");
|
||||
assert_eq!(loaded.shared_identity(), majority.shared_identity());
|
||||
assert!(disks[0].is_none(), "the foreign single-drive format must be quarantined");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_drive_pool_expansion_preserves_existing_format() {
|
||||
let (_original_dir, mut disks) = local_disks(4).await;
|
||||
let (_new_dir, mut new_disks) = local_disks(4).await;
|
||||
let original = connect_load_init_formats(true, &mut disks, 1, 4, None)
|
||||
.await
|
||||
.expect("original multi-drive pool should initialize");
|
||||
let before = format_bytes(&disks).await;
|
||||
|
||||
let added = connect_load_init_formats(true, &mut new_disks, 1, 4, Some(original.id))
|
||||
.await
|
||||
.expect("a new multi-drive pool should initialize with the existing deployment ID");
|
||||
assert_eq!(added.id, original.id);
|
||||
assert_ne!(added.erasure.sets, original.erasure.sets);
|
||||
assert_eq!(format_bytes(&disks).await, before);
|
||||
assert_eq!(
|
||||
connect_load_init_formats(true, &mut disks, 1, 4, Some(original.id))
|
||||
.await
|
||||
.expect("the original pool should restart with unchanged geometry"),
|
||||
original
|
||||
);
|
||||
assert_eq!(
|
||||
connect_load_init_formats(true, &mut new_disks, 1, 4, Some(original.id))
|
||||
.await
|
||||
.expect("the new pool should restart with its own format"),
|
||||
added
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_startup_rejects_pool_resize_before_retry_loop() {
|
||||
use crate::layout::endpoints::{EndpointServerPools, PoolEndpoints};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
for (stored_width, configured_width) in [(1, 4), (4, 8)] {
|
||||
let (_temp_dir, disks) = local_disks(configured_width).await;
|
||||
let original = FormatV3::new(1, stored_width);
|
||||
for (disk, disk_id) in disks.iter().zip(&original.erasure.sets[0]) {
|
||||
let mut format = original.clone();
|
||||
format.erasure.this = *disk_id;
|
||||
save_format_file(disk, &Some(format))
|
||||
.await
|
||||
.expect("old format should be written");
|
||||
}
|
||||
let before = format_bytes(&disks).await;
|
||||
let endpoints = disks.iter().flatten().map(|disk| disk.endpoint()).collect::<Vec<_>>();
|
||||
let pools = EndpointServerPools::from(vec![PoolEndpoints {
|
||||
legacy: true,
|
||||
set_count: 1,
|
||||
drives_per_set: configured_width,
|
||||
endpoints: Endpoints::from(endpoints),
|
||||
cmd_line: "test-pool".to_string(),
|
||||
platform: String::new(),
|
||||
}]);
|
||||
let shutdown = CancellationToken::new();
|
||||
let result = temp_env::async_with_vars(
|
||||
[
|
||||
(storageclass::STANDARD_ENV, None::<&str>),
|
||||
(storageclass::RRS_ENV, None::<&str>),
|
||||
(storageclass::OPTIMIZE_ENV, None::<&str>),
|
||||
(storageclass::INLINE_BLOCK_ENV, None::<&str>),
|
||||
],
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
crate::store::ECStore::new_with_instance_ctx(
|
||||
"127.0.0.1:0".parse().expect("test address"),
|
||||
pools,
|
||||
shutdown.clone(),
|
||||
Arc::new(InstanceContext::new()),
|
||||
),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
shutdown.cancel();
|
||||
let err = result
|
||||
.expect("invalid topology must abort without the format retry backoff")
|
||||
.expect_err("resize must fail");
|
||||
match stored_width {
|
||||
1 => assert!(matches!(err, Error::UnsupportedSnsdExpansion { configured_drives: 4 }), "{err}"),
|
||||
_ => assert!(
|
||||
matches!(
|
||||
err,
|
||||
Error::PoolTopologyMismatch {
|
||||
stored_drives: 4,
|
||||
configured_drives: 8,
|
||||
..
|
||||
}
|
||||
),
|
||||
"{err}"
|
||||
),
|
||||
}
|
||||
assert_eq!(format_bytes(&disks).await, before, "failed store startup must not write formats");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_format_load_rejects_conflicting_formats_without_a_majority() {
|
||||
let (_temp_dir, mut disks) = two_local_disks_with_missing_third().await;
|
||||
|
||||
@@ -527,6 +527,31 @@ impl Default for ScannerDataMovementPauseStatus {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct PoolMetaWriteGateStatus {
|
||||
pub writes_ready: bool,
|
||||
pub write_blocked: bool,
|
||||
pub transaction_aborted: bool,
|
||||
pub pool_meta_absent: bool,
|
||||
pub identity_initialized: Option<bool>,
|
||||
pub identity_needs_repair: bool,
|
||||
pub cluster_epoch: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for PoolMetaWriteGateStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
writes_ready: true,
|
||||
write_blocked: false,
|
||||
transaction_aborted: false,
|
||||
pool_meta_absent: false,
|
||||
identity_initialized: None,
|
||||
identity_needs_repair: false,
|
||||
cluster_epoch: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn offset_unix_seconds(value: OffsetDateTime) -> u64 {
|
||||
u64::try_from(value.unix_timestamp()).unwrap_or(0)
|
||||
}
|
||||
|
||||
@@ -526,12 +526,8 @@ impl ECStore {
|
||||
if self.single_pool() {
|
||||
self.apply_decommission_target_mutation_fence(0, object, &mut opts, mutation_fence)
|
||||
.await;
|
||||
return self
|
||||
.run_decommission_capacity_admitted_mutation(0, None, None, || async {
|
||||
self.pools[0].new_multipart_upload(bucket, object, &opts).await
|
||||
})
|
||||
.await
|
||||
.map(|res| (res, 0, opts.expected_bucket_incarnation_id));
|
||||
let result = self.pools[0].new_multipart_upload(bucket, object, &opts).await?;
|
||||
return Ok((result, 0, opts.expected_bucket_incarnation_id));
|
||||
}
|
||||
|
||||
if opts.data_movement && opts.version_id.is_some() {
|
||||
@@ -658,7 +654,9 @@ impl ECStore {
|
||||
) -> Result<PartInfo> {
|
||||
check_put_object_part_args(bucket, object, upload_id)?;
|
||||
let (mut opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
||||
opts.decommission_capacity_admission = crate::bucket::metadata_sys::object_store_if_initialized_in(&self.ctx).await;
|
||||
if !self.single_pool() {
|
||||
opts.decommission_capacity_admission = crate::bucket::metadata_sys::object_store_if_initialized_in(&self.ctx).await;
|
||||
}
|
||||
let opts = &opts;
|
||||
|
||||
if self.single_pool() {
|
||||
@@ -982,7 +980,9 @@ impl ECStore {
|
||||
) -> Result<ObjectInfo> {
|
||||
check_complete_multipart_args(bucket, object, upload_id)?;
|
||||
let (mut opts, _bucket_lifecycle_guard) = self.guard_multipart_bucket_incarnation(bucket, opts).await?;
|
||||
opts.decommission_capacity_admission = crate::bucket::metadata_sys::object_store_if_initialized_in(&self.ctx).await;
|
||||
if !self.single_pool() {
|
||||
opts.decommission_capacity_admission = crate::bucket::metadata_sys::object_store_if_initialized_in(&self.ctx).await;
|
||||
}
|
||||
let opts = &opts;
|
||||
|
||||
if self.single_pool() {
|
||||
|
||||
@@ -3123,6 +3123,9 @@ impl ECStore {
|
||||
Fut: std::future::Future<Output = Result<T>>,
|
||||
{
|
||||
let (lock_object, target_object) = objects;
|
||||
if self.single_pool() {
|
||||
return operation(opts).await;
|
||||
}
|
||||
let (capacity_guard, has_active_decommission) = if capacity_releasing {
|
||||
self.acquire_decommission_capacity_release_fence_with_active_source().await?
|
||||
} else {
|
||||
@@ -3220,6 +3223,9 @@ impl ECStore {
|
||||
F: FnOnce(HealOpts) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<T>>,
|
||||
{
|
||||
if self.single_pool() {
|
||||
return operation(opts).await;
|
||||
}
|
||||
let (capacity_guard, has_active_decommission) = self
|
||||
.acquire_external_decommission_capacity_fence_with_active_source(&[target_pool_idx], "heal")
|
||||
.await?;
|
||||
@@ -4162,7 +4168,9 @@ impl ECStore {
|
||||
.select_put_object_pool_idx(bucket, object.as_str(), data.size(), &opts)
|
||||
.await?;
|
||||
let mut opts = opts;
|
||||
opts.decommission_capacity_admission = crate::bucket::metadata_sys::object_store_if_initialized_in(&self.ctx).await;
|
||||
if !self.single_pool() {
|
||||
opts.decommission_capacity_admission = crate::bucket::metadata_sys::object_store_if_initialized_in(&self.ctx).await;
|
||||
}
|
||||
self.pools[idx]
|
||||
.put_object_with_old_current_size(bucket, object.as_str(), data, &opts)
|
||||
.await
|
||||
@@ -4170,6 +4178,24 @@ impl ECStore {
|
||||
|
||||
#[instrument(level = "trace", skip(self))]
|
||||
pub(super) async fn handle_get_object_info(&self, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<ObjectInfo> {
|
||||
self.get_object_info_snapshot(bucket, object, opts, false).await
|
||||
}
|
||||
|
||||
/// Return metadata for DELETE preflight, including an explicitly addressed
|
||||
/// delete marker. Read APIs must keep using `get_object_info`; authorization
|
||||
/// and Object Lock enforcement still belong to the caller and locked delete.
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
pub async fn get_object_info_for_delete(&self, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<ObjectInfo> {
|
||||
self.get_object_info_snapshot(bucket, object, opts, true).await
|
||||
}
|
||||
|
||||
async fn get_object_info_snapshot(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
opts: &ObjectOptions,
|
||||
allow_delete_marker: bool,
|
||||
) -> Result<ObjectInfo> {
|
||||
check_object_args(bucket, object)?;
|
||||
|
||||
let object = encode_dir_object(object);
|
||||
@@ -4180,6 +4206,8 @@ impl ECStore {
|
||||
|
||||
let info = if self.single_pool() {
|
||||
self.pools[0].get_object_info(bucket, object.as_str(), &opts).await?
|
||||
} else if allow_delete_marker {
|
||||
self.get_latest_object_info_with_idx(bucket, object.as_str(), &opts).await?.0
|
||||
} else {
|
||||
self.get_latest_accessible_object_info_with_idx(bucket, object.as_str(), &opts)
|
||||
.await?
|
||||
@@ -4340,8 +4368,10 @@ impl ECStore {
|
||||
object_lock_config_snapshot: dst_opts.object_lock_config_snapshot.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
put_opts.decommission_capacity_admission =
|
||||
crate::bucket::metadata_sys::object_store_if_initialized_in(&self.ctx).await;
|
||||
if !self.single_pool() {
|
||||
put_opts.decommission_capacity_admission =
|
||||
crate::bucket::metadata_sys::object_store_if_initialized_in(&self.ctx).await;
|
||||
}
|
||||
return if let Some(reader) = src_info.put_object_reader.as_mut() {
|
||||
self.pools[pool_idx]
|
||||
.put_object(dst_bucket, &dst_object, reader, &put_opts)
|
||||
@@ -4376,8 +4406,10 @@ impl ECStore {
|
||||
object_lock_config_snapshot: dst_opts.object_lock_config_snapshot.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
put_opts.decommission_capacity_admission =
|
||||
crate::bucket::metadata_sys::object_store_if_initialized_in(&self.ctx).await;
|
||||
if !self.single_pool() {
|
||||
put_opts.decommission_capacity_admission =
|
||||
crate::bucket::metadata_sys::object_store_if_initialized_in(&self.ctx).await;
|
||||
}
|
||||
return self.pools[pool_idx]
|
||||
.put_object(dst_bucket, &dst_object, reader, &put_opts)
|
||||
.await;
|
||||
@@ -4422,7 +4454,10 @@ impl ECStore {
|
||||
object_lock_config_snapshot: dst_opts.object_lock_config_snapshot.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
put_opts.decommission_capacity_admission = crate::bucket::metadata_sys::object_store_if_initialized_in(&self.ctx).await;
|
||||
if !self.single_pool() {
|
||||
put_opts.decommission_capacity_admission =
|
||||
crate::bucket::metadata_sys::object_store_if_initialized_in(&self.ctx).await;
|
||||
}
|
||||
|
||||
if let Some(put_object_reader) = src_info.put_object_reader.as_mut() {
|
||||
return self.pools[pool_idx]
|
||||
@@ -5057,7 +5092,7 @@ impl ECStore {
|
||||
}
|
||||
}
|
||||
|
||||
let _capacity_fence = if latest_marker_objects.iter().any(|creates_marker| *creates_marker) {
|
||||
let _capacity_fence = if !self.single_pool() && latest_marker_objects.iter().any(|creates_marker| *creates_marker) {
|
||||
let target_pool_indices = (0..self.pools.len()).collect::<Vec<_>>();
|
||||
match self
|
||||
.acquire_external_decommission_capacity_fence(&target_pool_indices, "batch_delete")
|
||||
@@ -5375,7 +5410,6 @@ impl ECStore {
|
||||
// self-deadlocked on the inner commits.
|
||||
let object_name = object.as_str();
|
||||
if self.single_pool() {
|
||||
opts.decommission_capacity_admission = Some(Arc::clone(&self));
|
||||
return self.pools[0]
|
||||
.clone()
|
||||
.restore_transitioned_object(bucket, object_name, &opts)
|
||||
|
||||
@@ -1146,7 +1146,11 @@ impl ECStore {
|
||||
}
|
||||
|
||||
let backend = StorageAdminApi::backend_info(self).await;
|
||||
rustfs_madmin::StorageInfo { backend, disks }
|
||||
rustfs_madmin::StorageInfo {
|
||||
backend,
|
||||
disks,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
|
||||
@@ -37,6 +37,9 @@ pub enum Error {
|
||||
#[error("Method not allowed")]
|
||||
MethodNotAllowed,
|
||||
|
||||
#[error("You've exceeded the limit on the number of versions you can create on this object")]
|
||||
MaxVersionsExceeded,
|
||||
|
||||
#[error("Unexpected error")]
|
||||
Unexpected,
|
||||
|
||||
@@ -86,6 +89,7 @@ impl PartialEq for Error {
|
||||
(Error::FileCorrupt, Error::FileCorrupt) => true,
|
||||
(Error::DoneForNow, Error::DoneForNow) => true,
|
||||
(Error::MethodNotAllowed, Error::MethodNotAllowed) => true,
|
||||
(Error::MaxVersionsExceeded, Error::MaxVersionsExceeded) => true,
|
||||
(Error::FileNotFound, Error::FileNotFound) => true,
|
||||
(Error::FileVersionNotFound, Error::FileVersionNotFound) => true,
|
||||
(Error::VolumeNotFound, Error::VolumeNotFound) => true,
|
||||
@@ -111,6 +115,7 @@ impl Clone for Error {
|
||||
Error::FileCorrupt => Error::FileCorrupt,
|
||||
Error::DoneForNow => Error::DoneForNow,
|
||||
Error::MethodNotAllowed => Error::MethodNotAllowed,
|
||||
Error::MaxVersionsExceeded => Error::MaxVersionsExceeded,
|
||||
Error::VolumeNotFound => Error::VolumeNotFound,
|
||||
Error::Io(e) => Error::Io(std::io::Error::new(e.kind(), e.to_string())),
|
||||
Error::RmpSerdeDecode(s) => Error::RmpSerdeDecode(s.clone()),
|
||||
|
||||
+134
-14
@@ -34,11 +34,14 @@ use rustfs_utils::http::{
|
||||
};
|
||||
use s3s::header::X_AMZ_RESTORE;
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(test)]
|
||||
use std::cell::Cell;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::TryFrom;
|
||||
use std::hash::Hasher;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
|
||||
use std::{collections::HashMap, io::Cursor};
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
@@ -67,8 +70,46 @@ const _XL_FLAG_INLINE_DATA: u8 = 1 << 2;
|
||||
const META_DATA_READ_DEFAULT: usize = 4 << 10;
|
||||
const MSGP_UINT32_SIZE: usize = 5;
|
||||
|
||||
/// Max object versions per object, default is 10000
|
||||
const DEFAULT_OBJECT_MAX_VERSIONS: usize = 10000;
|
||||
/// Default max object versions per object, aligned with MinIO's default.
|
||||
pub const DEFAULT_OBJECT_MAX_VERSIONS: usize = if usize::BITS >= 64 {
|
||||
9_223_372_036_854_775_807
|
||||
} else {
|
||||
usize::MAX
|
||||
};
|
||||
|
||||
static OBJECT_MAX_VERSIONS: AtomicUsize = AtomicUsize::new(DEFAULT_OBJECT_MAX_VERSIONS);
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static OBJECT_MAX_VERSIONS_OVERRIDE: Cell<Option<usize>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn object_max_versions() -> usize {
|
||||
#[cfg(test)]
|
||||
if let Some(limit) = OBJECT_MAX_VERSIONS_OVERRIDE.with(Cell::get) {
|
||||
return limit;
|
||||
}
|
||||
|
||||
OBJECT_MAX_VERSIONS.load(AtomicOrdering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn set_object_max_versions(limit: usize) -> Result<()> {
|
||||
if limit == 0 {
|
||||
return Err(Error::other("object max versions must be greater than 0"));
|
||||
}
|
||||
OBJECT_MAX_VERSIONS.store(limit, AtomicOrdering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn set_object_max_versions_override_for_test(limit: Option<usize>) -> Option<usize> {
|
||||
OBJECT_MAX_VERSIONS_OVERRIDE.with(|override_limit| {
|
||||
let previous = override_limit.get();
|
||||
override_limit.set(limit);
|
||||
previous
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the inline data map key for a version_id. "null" for null version.
|
||||
pub(crate) fn data_key_for_version(version_id: Option<Uuid>) -> String {
|
||||
@@ -460,18 +501,6 @@ impl FileMeta {
|
||||
return Err(Error::other("file meta version invalid"));
|
||||
}
|
||||
|
||||
// check max versions limit
|
||||
if self.versions.len() + 1 > DEFAULT_OBJECT_MAX_VERSIONS {
|
||||
return Err(Error::other(
|
||||
"You've exceeded the limit on the number of versions you can create on this object",
|
||||
));
|
||||
}
|
||||
|
||||
if self.versions.is_empty() {
|
||||
self.versions.push(FileMetaShallowVersion::try_from(version)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let vid = version.get_version_id();
|
||||
let vid_is_null = vid.is_none() || vid == Some(Uuid::nil());
|
||||
let existing_idx = if vid_is_null {
|
||||
@@ -490,6 +519,15 @@ impl FileMeta {
|
||||
return self.set_idx(fidx, version);
|
||||
}
|
||||
|
||||
if self.versions.len() >= object_max_versions() {
|
||||
return Err(Error::MaxVersionsExceeded);
|
||||
}
|
||||
|
||||
if self.versions.is_empty() {
|
||||
self.versions.push(FileMetaShallowVersion::try_from(version)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let new_shallow = FileMetaShallowVersion::try_from(version)?;
|
||||
let insert_pos = self
|
||||
.versions
|
||||
@@ -1330,6 +1368,88 @@ mod test {
|
||||
}
|
||||
}
|
||||
|
||||
struct ObjectMaxVersionsRestore {
|
||||
previous: Option<usize>,
|
||||
}
|
||||
|
||||
impl Drop for ObjectMaxVersionsRestore {
|
||||
fn drop(&mut self) {
|
||||
set_object_max_versions_override_for_test(self.previous);
|
||||
}
|
||||
}
|
||||
|
||||
fn with_object_max_versions_for_test<R>(limit: usize, test: impl FnOnce() -> R) -> R {
|
||||
let previous = set_object_max_versions_override_for_test(Some(limit));
|
||||
let _restore = ObjectMaxVersionsRestore { previous };
|
||||
test()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_version_filemata_rejects_new_version_above_configured_limit() {
|
||||
with_object_max_versions_for_test(2, || {
|
||||
let mut fm = FileMeta::new();
|
||||
fm.add_version_filemata(valid_object_version(Uuid::from_u128(1), vec![10, 20]))
|
||||
.expect("add first version within limit");
|
||||
fm.add_version_filemata(valid_object_version(Uuid::from_u128(2), vec![10, 20]))
|
||||
.expect("add second version at limit");
|
||||
|
||||
let err = fm
|
||||
.add_version_filemata(valid_object_version(Uuid::from_u128(3), vec![10, 20]))
|
||||
.expect_err("new version above limit must fail");
|
||||
|
||||
assert_eq!(err, Error::MaxVersionsExceeded);
|
||||
assert_eq!(fm.versions.len(), 2, "failed insert must not mutate version list");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_version_filemata_allows_same_version_replacement_at_limit() {
|
||||
with_object_max_versions_for_test(2, || {
|
||||
let mut fm = FileMeta::new();
|
||||
let target = Uuid::from_u128(10);
|
||||
fm.add_version_filemata(valid_object_version(target, vec![10, 20]))
|
||||
.expect("add target version");
|
||||
fm.add_version_filemata(valid_object_version(Uuid::from_u128(20), vec![10, 20]))
|
||||
.expect("add peer version at limit");
|
||||
|
||||
fm.add_version_filemata(valid_object_version(target, vec![30, 40]))
|
||||
.expect("same version replacement at limit must succeed");
|
||||
|
||||
assert_eq!(fm.versions.len(), 2);
|
||||
let replaced = fm
|
||||
.versions
|
||||
.iter()
|
||||
.find(|version| version.header.version_id == Some(target))
|
||||
.expect("target version must remain present")
|
||||
.parse_version_meta()
|
||||
.expect("parse replaced version");
|
||||
assert_eq!(replaced.object.expect("object version").part_sizes, vec![30, 40]);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_version_allows_null_version_replacement_at_limit() {
|
||||
with_object_max_versions_for_test(1, || {
|
||||
let mut fm = FileMeta::new();
|
||||
let mut first = FileInfo::new("object", 2, 2);
|
||||
first.mod_time = Some(OffsetDateTime::now_utc());
|
||||
first.version_id = None;
|
||||
fm.add_version(first).expect("add initial null version");
|
||||
|
||||
let mut replacement = FileInfo::new("object", 2, 2);
|
||||
replacement.mod_time = Some(OffsetDateTime::now_utc());
|
||||
replacement.version_id = None;
|
||||
replacement.size = 42;
|
||||
fm.add_version(replacement)
|
||||
.expect("null version replacement at limit must succeed");
|
||||
|
||||
assert_eq!(fm.versions.len(), 1);
|
||||
assert_eq!(fm.versions[0].header.version_id, Some(Uuid::nil()));
|
||||
let replaced = fm.versions[0].parse_version_meta().expect("parse null replacement");
|
||||
assert_eq!(replaced.object.expect("object version").size, 42);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_version_filemata_uses_canonical_equal_time_order() {
|
||||
let mod_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid test timestamp");
|
||||
|
||||
@@ -442,6 +442,10 @@ pub struct ReplicatedTargetInfo {
|
||||
pub error: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target_delete_marker_version_id: Option<String>,
|
||||
/// Kept in step with the replication crate's copy: the id a target that
|
||||
/// mints its own version ids assigned to this object version.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target_version_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ReplicatedTargetInfo {
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::heal::{
|
||||
task::{HealOptions, HealPriority, HealRequest, HealTask, HealTaskStatus, HealType, demote_to_debug_when},
|
||||
};
|
||||
use crate::{Error, Result};
|
||||
use metrics::{counter, gauge};
|
||||
use metrics::{counter, gauge, histogram};
|
||||
use rustfs_concurrency::WorkloadAdmissionSnapshotProvider;
|
||||
use rustfs_concurrency::workload::{ForegroundPressure, foreground_pressure};
|
||||
#[cfg(test)]
|
||||
@@ -34,7 +34,7 @@ use std::sync::LazyLock;
|
||||
use std::{
|
||||
collections::{BinaryHeap, HashMap, HashSet},
|
||||
sync::{Arc, Mutex as StdMutex, MutexGuard as StdMutexGuard},
|
||||
time::{Duration, SystemTime},
|
||||
time::{Duration, Instant, SystemTime},
|
||||
};
|
||||
use tokio::{
|
||||
sync::{Mutex, Notify, RwLock},
|
||||
@@ -181,6 +181,13 @@ fn lock_displaced_terminals(
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_admission_telemetry(registry: &StdMutex<HealAdmissionTelemetry>) -> StdMutexGuard<'_, HealAdmissionTelemetry> {
|
||||
match registry.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_displaced_terminal(
|
||||
registry: &StdMutex<HashMap<String, Arc<CompletedHealStatus>>>,
|
||||
request: &HealRequest,
|
||||
@@ -384,6 +391,61 @@ impl HealSourceCounts {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct HealAdmissionTelemetry {
|
||||
pub accepted: u64,
|
||||
pub merged: u64,
|
||||
pub full: u64,
|
||||
pub dropped: u64,
|
||||
pub duplicate: u64,
|
||||
pub overlap_rejected: u64,
|
||||
pub displaced: u64,
|
||||
pub force_start: u64,
|
||||
pub max_start_duration_micros: u64,
|
||||
pub max_lock_phase_micros: u64,
|
||||
}
|
||||
|
||||
impl HealAdmissionTelemetry {
|
||||
fn record(&mut self, observation: HealAdmissionObservation) {
|
||||
match observation.result {
|
||||
HealAdmissionResult::Accepted => self.accepted = self.accepted.saturating_add(1),
|
||||
HealAdmissionResult::Merged => self.merged = self.merged.saturating_add(1),
|
||||
HealAdmissionResult::Full => self.full = self.full.saturating_add(1),
|
||||
HealAdmissionResult::Dropped(_) => self.dropped = self.dropped.saturating_add(1),
|
||||
}
|
||||
if observation.context == "duplicate" {
|
||||
self.duplicate = self.duplicate.saturating_add(1);
|
||||
}
|
||||
if observation.context == "overlap_rejected" {
|
||||
self.overlap_rejected = self.overlap_rejected.saturating_add(1);
|
||||
}
|
||||
if observation.displaced {
|
||||
self.displaced = self.displaced.saturating_add(1);
|
||||
}
|
||||
if observation.force_start {
|
||||
self.force_start = self.force_start.saturating_add(1);
|
||||
}
|
||||
self.max_start_duration_micros = self
|
||||
.max_start_duration_micros
|
||||
.max(duration_micros_saturated(observation.start_duration));
|
||||
self.max_lock_phase_micros = self
|
||||
.max_lock_phase_micros
|
||||
.max(duration_micros_saturated(observation.lock_phase));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct HealAdmissionObservation {
|
||||
source: HealRequestSource,
|
||||
result: HealAdmissionResult,
|
||||
context: &'static str,
|
||||
force_start: bool,
|
||||
displaced: bool,
|
||||
start_duration: Duration,
|
||||
lock_phase: Duration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct HealOperationsSnapshot {
|
||||
@@ -396,12 +458,18 @@ pub struct HealOperationsSnapshot {
|
||||
pub queued_by_source: HealSourceCounts,
|
||||
pub active_by_source: HealSourceCounts,
|
||||
pub retrying_by_source: HealSourceCounts,
|
||||
#[serde(default)]
|
||||
pub admission: HealAdmissionTelemetry,
|
||||
}
|
||||
|
||||
fn usize_to_u64_saturated(value: usize) -> u64 {
|
||||
u64::try_from(value).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
fn duration_micros_saturated(duration: Duration) -> u64 {
|
||||
u64::try_from(duration.as_micros()).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
fn heal_type_matches_path(heal_type: &HealType, heal_path: &str) -> bool {
|
||||
let heal_path = heal_path.trim_matches('/');
|
||||
if heal_path.is_empty() || heal_path == LEGACY_ROOT_HEAL_PATH {
|
||||
@@ -764,6 +832,9 @@ pub struct HealManager {
|
||||
notify: Arc<Notify>,
|
||||
/// Optional runtime workload snapshot provider used to protect foreground data-plane work.
|
||||
workload_provider: Option<WorkloadSnapshotProviderRef>,
|
||||
/// Bounded, low-cardinality admission telemetry exposed through the
|
||||
/// existing operations snapshot for cluster E2E assertions.
|
||||
admission_telemetry: Arc<StdMutex<HealAdmissionTelemetry>>,
|
||||
}
|
||||
|
||||
/// Where a task-id lookup resolved. The variants carry the resolved state
|
||||
@@ -919,6 +990,33 @@ impl HealManager {
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
fn record_admission_observation(&self, observation: HealAdmissionObservation) {
|
||||
let result = observation.result.result_label().to_string();
|
||||
let reason = observation.result.reason_label().to_string();
|
||||
let source = observation.source.as_str().to_string();
|
||||
let context = observation.context.to_string();
|
||||
let force_start = observation.force_start.to_string();
|
||||
histogram!(
|
||||
"rustfs_heal_admission_start_duration_seconds",
|
||||
"source" => source.clone(),
|
||||
"result" => result.clone(),
|
||||
"reason" => reason.clone(),
|
||||
"context" => context.clone(),
|
||||
"force_start" => force_start.clone()
|
||||
)
|
||||
.record(observation.start_duration.as_secs_f64());
|
||||
histogram!(
|
||||
"rustfs_heal_admission_lock_phase_seconds",
|
||||
"source" => source,
|
||||
"result" => result,
|
||||
"reason" => reason,
|
||||
"context" => context,
|
||||
"force_start" => force_start
|
||||
)
|
||||
.record(observation.lock_phase.as_secs_f64());
|
||||
lock_admission_telemetry(&self.admission_telemetry).record(observation);
|
||||
}
|
||||
|
||||
fn remove_mrf_repair_notice_targets_for_task(&self, task_id: &str) {
|
||||
let targets = lock_mrf_repair_notice_targets(&self.mrf_repair_notice_targets).remove(task_id);
|
||||
if let Some(targets) = targets {
|
||||
@@ -1265,6 +1363,7 @@ impl HealManager {
|
||||
statistics: Arc::new(RwLock::new(HealStatistics::new())),
|
||||
notify: Arc::new(Notify::new()),
|
||||
workload_provider,
|
||||
admission_telemetry: Arc::new(StdMutex::new(HealAdmissionTelemetry::default())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1455,6 +1554,9 @@ impl HealManager {
|
||||
preserve_alias: bool,
|
||||
mrf_notice_target: Option<MrfRepairNoticeTarget>,
|
||||
) -> Result<HealAdmissionReceipt> {
|
||||
let admission_start = Instant::now();
|
||||
let source = request.source;
|
||||
let force_start = request.force_start;
|
||||
// HS-06 forceStart semantics (admin only): MinIO stops the old task
|
||||
// first and then starts the new one. Cancel any active admin task
|
||||
// overlapping this request's path before entering admission, so the
|
||||
@@ -1505,6 +1607,7 @@ impl HealManager {
|
||||
// Match the scheduler's active -> queue order and keep retry ownership
|
||||
// in the same atomic view. Otherwise queue -> active and
|
||||
// active -> retrying transitions can slip between duplicate checks.
|
||||
let lock_phase_start = Instant::now();
|
||||
let active_heals = self.active_heals.lock().await;
|
||||
#[cfg(test)]
|
||||
pause_duplicate_admission_after_active_lock(&request.id).await;
|
||||
@@ -1539,7 +1642,17 @@ impl HealManager {
|
||||
drop(retrying_heals);
|
||||
drop(queue);
|
||||
drop(active_heals);
|
||||
let lock_phase = lock_phase_start.elapsed();
|
||||
Self::record_admission_metric(request.source, admission, "duplicate");
|
||||
self.record_admission_observation(HealAdmissionObservation {
|
||||
source,
|
||||
result: admission,
|
||||
context: "duplicate",
|
||||
force_start,
|
||||
displaced: false,
|
||||
start_duration: admission_start.elapsed(),
|
||||
lock_phase,
|
||||
});
|
||||
|
||||
match admission {
|
||||
HealAdmissionResult::Merged => {
|
||||
@@ -1618,7 +1731,17 @@ impl HealManager {
|
||||
drop(retrying_heals);
|
||||
drop(queue);
|
||||
drop(active_heals);
|
||||
let lock_phase = lock_phase_start.elapsed();
|
||||
Self::record_admission_metric(request.source, HealAdmissionResult::Dropped(reason), "overlap_rejected");
|
||||
self.record_admission_observation(HealAdmissionObservation {
|
||||
source,
|
||||
result: HealAdmissionResult::Dropped(reason),
|
||||
context: "overlap_rejected",
|
||||
force_start,
|
||||
displaced: false,
|
||||
start_duration: admission_start.elapsed(),
|
||||
lock_phase,
|
||||
});
|
||||
warn!(
|
||||
target: "rustfs::heal::manager",
|
||||
event = EVENT_HEAL_QUEUE_ADMISSION,
|
||||
@@ -1663,6 +1786,8 @@ impl HealManager {
|
||||
drop(retrying_heals);
|
||||
drop(queue);
|
||||
drop(active_heals);
|
||||
let lock_phase = lock_phase_start.elapsed();
|
||||
let displaced = displaced_terminal.is_some();
|
||||
|
||||
if let (Some(displaced_task_id), Some(displaced_terminal)) = (displaced_task_id, displaced_terminal) {
|
||||
// The queue has already removed the displaced request, so the
|
||||
@@ -1676,6 +1801,16 @@ impl HealManager {
|
||||
self.notify.notify_one();
|
||||
}
|
||||
|
||||
self.record_admission_observation(HealAdmissionObservation {
|
||||
source,
|
||||
result: admission,
|
||||
context: "submit",
|
||||
force_start,
|
||||
displaced,
|
||||
start_duration: admission_start.elapsed(),
|
||||
lock_phase,
|
||||
});
|
||||
|
||||
Ok(HealAdmissionReceipt {
|
||||
result: admission,
|
||||
task_id,
|
||||
@@ -2111,17 +2246,25 @@ impl HealManager {
|
||||
}
|
||||
publish_active_heal_count(&active_heals);
|
||||
publish_heal_queue_length(&queue);
|
||||
let queue_length = usize_to_u64_saturated(queue.len());
|
||||
let active_tasks = usize_to_u64_saturated(active_heals.len());
|
||||
let retrying_tasks = usize_to_u64_saturated(retrying_heals.len());
|
||||
drop(retrying_heals);
|
||||
drop(queue);
|
||||
drop(active_heals);
|
||||
let admission = *lock_admission_telemetry(&self.admission_telemetry);
|
||||
|
||||
HealOperationsSnapshot {
|
||||
queue_length: usize_to_u64_saturated(queue.len()),
|
||||
active_tasks: usize_to_u64_saturated(active_heals.len()),
|
||||
retrying_tasks: usize_to_u64_saturated(retrying_heals.len()),
|
||||
queue_length,
|
||||
active_tasks,
|
||||
retrying_tasks,
|
||||
queued_by_priority,
|
||||
active_by_priority,
|
||||
retrying_by_priority,
|
||||
queued_by_source,
|
||||
active_by_source,
|
||||
retrying_by_source,
|
||||
admission,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2792,6 +2792,88 @@ async fn admin_force_start_cancels_overlapping_active_task_first() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admission_snapshot_tracks_start_duplicate_force_start_and_displacement() {
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||
let manager = Arc::new(HealManager::new(
|
||||
storage,
|
||||
Some(HealConfig {
|
||||
queue_size: 1,
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
|
||||
let mut paused = admin_prefix_request("bucket-a", "logs/");
|
||||
paused.priority = HealPriority::Low;
|
||||
let hook = Arc::new(DuplicateAdmissionTestHook {
|
||||
request_id: paused.id.clone(),
|
||||
active_lock_reached: Notify::new(),
|
||||
active_lock_release: Notify::new(),
|
||||
});
|
||||
*DUPLICATE_ADMISSION_TEST_HOOK.lock().await = Some(hook.clone());
|
||||
|
||||
let submit_manager = Arc::clone(&manager);
|
||||
let mut paused_submission = tokio::spawn(async move { submit_manager.submit_heal_request(paused).await });
|
||||
tokio::time::timeout(Duration::from_secs(1), hook.active_lock_reached.notified())
|
||||
.await
|
||||
.expect("admission should reach the test-only lock phase hook");
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(10), &mut paused_submission)
|
||||
.await
|
||||
.is_err(),
|
||||
"admission must wait while the lock-phase hook is held"
|
||||
);
|
||||
hook.active_lock_release.notify_one();
|
||||
assert_eq!(
|
||||
paused_submission
|
||||
.await
|
||||
.expect("paused admission task should join")
|
||||
.expect("paused admission should succeed"),
|
||||
HealAdmissionResult::Accepted
|
||||
);
|
||||
*DUPLICATE_ADMISSION_TEST_HOOK.lock().await = None;
|
||||
|
||||
let duplicate = admin_prefix_request("bucket-a", "logs/");
|
||||
let duplicate_receipt = manager
|
||||
.submit_heal_request_with_receipt(duplicate)
|
||||
.await
|
||||
.expect("duplicate admission should return a canonical receipt");
|
||||
assert_eq!(duplicate_receipt.result, HealAdmissionResult::Merged);
|
||||
|
||||
let mut high = admin_prefix_request("bucket-b", "logs/");
|
||||
high.priority = HealPriority::High;
|
||||
assert_eq!(
|
||||
manager
|
||||
.submit_heal_request(high)
|
||||
.await
|
||||
.expect("higher priority admin request should displace queued low-priority work"),
|
||||
HealAdmissionResult::Accepted
|
||||
);
|
||||
|
||||
let mut forced = admin_prefix_request("bucket-c", "logs/");
|
||||
forced.force_start = true;
|
||||
assert_eq!(
|
||||
manager
|
||||
.submit_heal_request(forced)
|
||||
.await
|
||||
.expect("forceStart should keep explicit admission semantics"),
|
||||
HealAdmissionResult::Accepted
|
||||
);
|
||||
|
||||
let admission = manager.operations_snapshot().await.admission;
|
||||
assert_eq!(admission.accepted, 3);
|
||||
assert_eq!(admission.merged, 1);
|
||||
assert_eq!(admission.full, 0);
|
||||
assert_eq!(admission.dropped, 0);
|
||||
assert_eq!(admission.duplicate, 1);
|
||||
assert_eq!(admission.displaced, 1);
|
||||
assert_eq!(admission.force_start, 1);
|
||||
assert!(
|
||||
admission.max_lock_phase_micros > 0,
|
||||
"snapshot should expose a measurable queue/admission lock phase for p95-style external aggregation"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_operations_snapshot_counts_active_by_source_and_priority() {
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||
|
||||
@@ -34,7 +34,7 @@ use storage_api::owner::{
|
||||
};
|
||||
|
||||
pub use erasure_healer::ErasureSetHealer;
|
||||
pub use manager::{HealManager, HealOperationsSnapshot, HealPriorityCounts, HealSourceCounts};
|
||||
pub use manager::{HealAdmissionTelemetry, HealManager, HealOperationsSnapshot, HealPriorityCounts, HealSourceCounts};
|
||||
pub use resume::{CheckpointManager, ResumeCheckpoint, ResumeManager, ResumeState, ResumeUtils};
|
||||
pub use task::{HealOptions, HealPriority, HealRequest, HealTask, HealType};
|
||||
|
||||
|
||||
@@ -516,6 +516,7 @@ async fn submit_mrf_heal_request(manager: &HealManager, intent: &MrfIntent) -> c
|
||||
|
||||
struct MrfRuntime {
|
||||
queue: MrfQueue,
|
||||
retained_replay_intents: Vec<MrfIntent>,
|
||||
config: MrfConsumerConfig,
|
||||
new_since_flush: usize,
|
||||
/// True while the in-memory pending set has changed since the last
|
||||
@@ -524,9 +525,8 @@ struct MrfRuntime {
|
||||
/// waiting out an admission backoff must not re-fsync every local disk
|
||||
/// twice a second.
|
||||
dirty: bool,
|
||||
/// True while a journal snapshot exists on disk that no longer reflects
|
||||
/// an all-consumed pending set; the next idle tick removes it (MinIO
|
||||
/// deletes its `list.bin` after replay for the same reason).
|
||||
/// True while a journal snapshot exists on disk that may still be needed
|
||||
/// for replay or cleanup.
|
||||
journal_on_disk: bool,
|
||||
/// Earliest instant a full-admission retry may proceed.
|
||||
backoff_until: Option<tokio::time::Instant>,
|
||||
@@ -536,7 +536,7 @@ impl MrfRuntime {
|
||||
fn snapshot(&self) -> (Vec<u8>, Vec<u8>) {
|
||||
let mut authoritative = Vec::new();
|
||||
let mut legacy = Vec::new();
|
||||
for intent in self.queue.intents() {
|
||||
for intent in self.retained_replay_intents.iter().chain(self.queue.intents()) {
|
||||
let scoped_identity =
|
||||
!matches!(intent.kind, rustfs_common::mrf_channel::MrfKind::MetadataCorruption) && intent.scope.is_some();
|
||||
if !encode_intent(intent, &mut authoritative) {
|
||||
@@ -674,10 +674,11 @@ pub async fn replay_journal_once(manager: &Arc<HealManager>) -> usize {
|
||||
struct ReplayOutcome {
|
||||
replayed: usize,
|
||||
journal_on_disk: bool,
|
||||
retained_replay_intents: Vec<MrfIntent>,
|
||||
}
|
||||
|
||||
fn replay_must_retain_journal(rearm_incomplete: bool, pending_depth: usize) -> bool {
|
||||
rearm_incomplete || pending_depth > 0
|
||||
fn replay_must_retain_journal(rearm_incomplete: bool, pending_depth: usize, retained_replay_depth: usize) -> bool {
|
||||
rearm_incomplete || pending_depth > 0 || retained_replay_depth > 0
|
||||
}
|
||||
|
||||
/// Shared replay core: read + decode + re-arm, then drain what fits. The
|
||||
@@ -699,6 +700,7 @@ async fn replay_into(
|
||||
return ReplayOutcome {
|
||||
replayed: 0,
|
||||
journal_on_disk: false,
|
||||
retained_replay_intents: Vec::new(),
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -738,23 +740,42 @@ async fn replay_into(
|
||||
|
||||
// Drain the replayed intents immediately; whatever the manager refuses
|
||||
// stays armed in `queue` for the consumer's retry loop.
|
||||
let mut retained_replay_intents = Vec::new();
|
||||
if backoff_until.is_none() {
|
||||
while let Some(mut intent) = queue.pop_front() {
|
||||
match submit_mrf_heal_request(manager, &intent).await {
|
||||
Ok(HealAdmissionResult::Accepted) | Ok(HealAdmissionResult::Merged) => {}
|
||||
Ok(HealAdmissionResult::Accepted) | Ok(HealAdmissionResult::Merged) => {
|
||||
retained_replay_intents.push(intent);
|
||||
}
|
||||
Ok(HealAdmissionResult::Full) | Ok(HealAdmissionResult::Dropped(HealAdmissionDropReason::QueueFull)) => {
|
||||
intent.attempts = intent.attempts.saturating_add(1);
|
||||
if intent.attempts < MRF_MAX_ATTEMPTS {
|
||||
queue.push_back(intent);
|
||||
*backoff_until = Some(tokio::time::Instant::now());
|
||||
} else {
|
||||
rearm_incomplete = true;
|
||||
counter!("rustfs_heal_mrf_dropped_total", "reason" => "attempts_exhausted").increment(1);
|
||||
rustfs_common::mrf_channel::release_mrf_intent(&intent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(HealAdmissionResult::Dropped(_)) => {}
|
||||
Err(_) => {
|
||||
intent.attempts = intent.attempts.saturating_add(1);
|
||||
if intent.attempts < MRF_MAX_ATTEMPTS {
|
||||
queue.push_back(intent);
|
||||
*backoff_until = Some(tokio::time::Instant::now());
|
||||
} else {
|
||||
rearm_incomplete = true;
|
||||
counter!("rustfs_heal_mrf_dropped_total", "reason" => "attempts_exhausted").increment(1);
|
||||
rustfs_common::mrf_channel::release_mrf_intent(&intent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(HealAdmissionResult::Dropped(_)) | Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let journal_on_disk = if replay_must_retain_journal(rearm_incomplete, queue.depth()) {
|
||||
let journal_on_disk = if replay_must_retain_journal(rearm_incomplete, queue.depth(), retained_replay_intents.len()) {
|
||||
true
|
||||
} else {
|
||||
!delete_journals().await
|
||||
@@ -762,6 +783,7 @@ async fn replay_into(
|
||||
ReplayOutcome {
|
||||
replayed,
|
||||
journal_on_disk,
|
||||
retained_replay_intents,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -771,6 +793,7 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
|
||||
let config = MrfConsumerConfig::default();
|
||||
let mut runtime = MrfRuntime {
|
||||
queue: MrfQueue::new(config.queue_capacity, config.journal_max_bytes),
|
||||
retained_replay_intents: Vec::new(),
|
||||
config: config.clone(),
|
||||
new_since_flush: 0,
|
||||
dirty: false,
|
||||
@@ -782,6 +805,7 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
|
||||
// on disk whenever any replayed intent still needs a successor snapshot.
|
||||
let replay = replay_into(&manager, &mut runtime.queue, &mut runtime.backoff_until).await;
|
||||
runtime.journal_on_disk = replay.journal_on_disk;
|
||||
runtime.retained_replay_intents = replay.retained_replay_intents;
|
||||
// Anything still pending (e.g. the manager was full and backoff armed)
|
||||
// must be re-persisted by the next flush before replay can delete the
|
||||
// startup anchor.
|
||||
@@ -799,7 +823,7 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
|
||||
// provably current AND idle (a dirty or pending state
|
||||
// gets one last persist attempt, matching the shutdown
|
||||
// retry the unconditional flush used to provide).
|
||||
if runtime.dirty || runtime.queue.depth() > 0 {
|
||||
if runtime.dirty || runtime.queue.depth() > 0 || !runtime.retained_replay_intents.is_empty() {
|
||||
runtime.flush().await;
|
||||
}
|
||||
tracing::info!(
|
||||
@@ -825,7 +849,12 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
|
||||
}
|
||||
}
|
||||
_ = flush_tick.tick() => {
|
||||
match tick_action(runtime.dirty, runtime.queue.depth(), runtime.journal_on_disk) {
|
||||
match tick_action(
|
||||
runtime.dirty,
|
||||
runtime.queue.depth(),
|
||||
runtime.retained_replay_intents.len(),
|
||||
runtime.journal_on_disk,
|
||||
) {
|
||||
TickAction::Flush => {
|
||||
runtime.flush().await;
|
||||
runtime.dispatch(manager.as_ref()).await;
|
||||
@@ -838,8 +867,8 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
|
||||
runtime.dispatch(manager.as_ref()).await;
|
||||
}
|
||||
TickAction::DeleteJournal => {
|
||||
// All intents consumed: remove the journal so a restart
|
||||
// replays nothing (mirrors MinIO's post-replay unlink).
|
||||
// Only remove a stale journal after every replayed
|
||||
// intent has a durable successor proof.
|
||||
if delete_journals().await {
|
||||
runtime.journal_on_disk = false;
|
||||
gauge!("rustfs_heal_mrf_journal_bytes").set(0.0);
|
||||
@@ -868,11 +897,13 @@ enum TickAction {
|
||||
Idle,
|
||||
}
|
||||
|
||||
fn tick_action(dirty: bool, depth: usize, journal_on_disk: bool) -> TickAction {
|
||||
fn tick_action(dirty: bool, depth: usize, retained_replay_depth: usize, journal_on_disk: bool) -> TickAction {
|
||||
if dirty {
|
||||
TickAction::Flush
|
||||
} else if depth > 0 {
|
||||
TickAction::Retry
|
||||
} else if retained_replay_depth > 0 {
|
||||
TickAction::Idle
|
||||
} else if journal_on_disk {
|
||||
TickAction::DeleteJournal
|
||||
} else {
|
||||
@@ -905,34 +936,73 @@ mod tests {
|
||||
|
||||
// Dirty dominates: a changed pending set flushes even when idle
|
||||
// otherwise.
|
||||
assert!(matches!(tick_action(true, 0, false), Flush));
|
||||
assert!(matches!(tick_action(true, 3, true), Flush));
|
||||
assert!(matches!(tick_action(true, 0, 0, false), Flush));
|
||||
assert!(matches!(tick_action(true, 3, 0, true), Flush));
|
||||
|
||||
// Clean backlog: no rewrite, but keep draining so an expired
|
||||
// admission backoff retries on time.
|
||||
assert!(matches!(tick_action(false, 1, false), Retry));
|
||||
assert!(matches!(tick_action(false, 2, true), Retry));
|
||||
assert!(matches!(tick_action(false, 1, 0, false), Retry));
|
||||
assert!(matches!(tick_action(false, 2, 0, true), Retry));
|
||||
|
||||
// Replayed records accepted by the manager are still restart anchors
|
||||
// until a durable successor proof can tombstone them.
|
||||
assert!(matches!(tick_action(false, 0, 1, true), Idle));
|
||||
|
||||
// Quiescent with a stale journal file on disk: remove it.
|
||||
assert!(matches!(tick_action(false, 0, true), DeleteJournal));
|
||||
assert!(matches!(tick_action(false, 0, 0, true), DeleteJournal));
|
||||
|
||||
// Fully quiescent: nothing to do.
|
||||
assert!(matches!(tick_action(false, 0, false), Idle));
|
||||
assert!(matches!(tick_action(false, 0, 0, false), Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_cleanup_retains_journal_for_unarmed_or_refused_records() {
|
||||
assert!(
|
||||
replay_must_retain_journal(true, 0),
|
||||
replay_must_retain_journal(true, 0, 0),
|
||||
"a rejected replay record still needs its disk anchor"
|
||||
);
|
||||
assert!(
|
||||
replay_must_retain_journal(false, 1),
|
||||
replay_must_retain_journal(false, 1, 0),
|
||||
"a Full admission retry must keep the startup journal until the next snapshot"
|
||||
);
|
||||
assert!(
|
||||
!replay_must_retain_journal(false, 0),
|
||||
"only a fully consumed replay snapshot may be deleted"
|
||||
replay_must_retain_journal(false, 0, 1),
|
||||
"an accepted replay record still needs a durable successor before cleanup"
|
||||
);
|
||||
assert!(
|
||||
!replay_must_retain_journal(false, 0, 0),
|
||||
"only a fully consumed replay snapshot with no retained anchors may be deleted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_replay_anchor_remains_in_successor_snapshot() {
|
||||
let retained = intent("accepted-replay", "object", 0);
|
||||
let mut runtime = MrfRuntime {
|
||||
queue: MrfQueue::new(8, 8192),
|
||||
retained_replay_intents: vec![retained.clone()],
|
||||
config: MrfConsumerConfig::default(),
|
||||
new_since_flush: 0,
|
||||
dirty: false,
|
||||
journal_on_disk: true,
|
||||
backoff_until: None,
|
||||
};
|
||||
assert_eq!(
|
||||
runtime.queue.try_push_typed(intent("new-pending", "object", 0)),
|
||||
MrfQueuePushResult::Enqueued
|
||||
);
|
||||
|
||||
let (authoritative, legacy) = runtime.snapshot();
|
||||
let (decoded, truncated) = decode_journal(&authoritative);
|
||||
let (legacy_decoded, legacy_truncated) = decode_journal(&legacy);
|
||||
|
||||
assert_eq!(truncated, 0);
|
||||
assert_eq!(legacy_truncated, 0);
|
||||
assert_eq!(decoded.len(), 2);
|
||||
assert_eq!(legacy_decoded.len(), 2);
|
||||
assert!(
|
||||
decoded.iter().any(|intent| intent.bucket == retained.bucket),
|
||||
"accepted replay anchor must remain crash-replayable"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,29 @@ pub struct HealObjectOutcome {
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HealObjectReceipt {
|
||||
pub identity: HealObjectIdentity,
|
||||
pub disposition: HealObjectDisposition,
|
||||
}
|
||||
|
||||
impl HealObjectReceipt {
|
||||
pub(crate) fn verified_for(&self, expected: &HealObjectIdentity) -> bool {
|
||||
matches!(
|
||||
self.disposition,
|
||||
HealObjectDisposition::Repaired
|
||||
| HealObjectDisposition::VerifiedHealthy
|
||||
| HealObjectDisposition::AuthoritativelyAbsent
|
||||
) && self.identity.kind == expected.kind
|
||||
&& self.identity.bucket == expected.bucket
|
||||
&& self.identity.object == expected.object
|
||||
&& self.identity.version_id == expected.version_id
|
||||
&& self.identity.pool_index == expected.pool_index
|
||||
&& self.identity.set_index == expected.set_index
|
||||
&& self.identity.bucket_incarnation_id.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl HealObjectOutcome {
|
||||
fn retained_bytes(&self) -> usize {
|
||||
size_of::<Self>()
|
||||
@@ -470,4 +493,48 @@ mod canonical_outcome_tests {
|
||||
assert_eq!(outcome.counters.processed, u64::MAX);
|
||||
assert_eq!(outcome.coverage, HealTraversalCoverage::Partial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positive_receipt_requires_exact_identity_and_bucket_incarnation() {
|
||||
let expected = item(HealObjectDisposition::Unknown).identity;
|
||||
let mut receipt = HealObjectReceipt {
|
||||
identity: expected.clone(),
|
||||
disposition: HealObjectDisposition::Repaired,
|
||||
};
|
||||
|
||||
assert!(
|
||||
!receipt.verified_for(&expected),
|
||||
"a positive storage receipt without bucket incarnation must remain untrusted"
|
||||
);
|
||||
|
||||
let incarnation = Uuid::new_v4();
|
||||
receipt.identity.bucket_incarnation_id = Some(incarnation);
|
||||
assert!(receipt.verified_for(&expected));
|
||||
|
||||
receipt.identity.version_id = Some("older-version".to_string());
|
||||
assert!(
|
||||
!receipt.verified_for(&expected),
|
||||
"a storage receipt for a different object/version tuple must not clear the requested responsibility"
|
||||
);
|
||||
|
||||
receipt.identity = HealObjectIdentity {
|
||||
bucket_incarnation_id: Some(incarnation),
|
||||
pool_index: Some(1),
|
||||
..expected.clone()
|
||||
};
|
||||
assert!(
|
||||
!receipt.verified_for(&expected),
|
||||
"a storage receipt for a different erasure location must not clear the requested responsibility"
|
||||
);
|
||||
|
||||
receipt.identity = HealObjectIdentity {
|
||||
bucket_incarnation_id: Some(incarnation),
|
||||
..expected
|
||||
};
|
||||
receipt.disposition = HealObjectDisposition::Unknown;
|
||||
assert!(
|
||||
!receipt.verified_for(&receipt.identity),
|
||||
"legacy success without a positive disposition remains unknown"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,12 +15,13 @@
|
||||
use crate::{Error, Result};
|
||||
use async_trait::async_trait;
|
||||
use base64_simd::URL_SAFE_NO_PAD;
|
||||
use rustfs_heal_contracts::heal_channel::{HealOpts, HealScanMode};
|
||||
use rustfs_heal_contracts::heal_channel::{DriveState, HealOpts, HealScanMode};
|
||||
use rustfs_madmin::heal_commands::HealResultItem;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use super::outcome::{HealObjectDisposition, HealObjectIdentity, HealObjectKind, HealObjectReceipt};
|
||||
use super::progress::stable_generation;
|
||||
use super::storage_api::owner::{EcstoreHealLifecycleExpiryContext, ecstore_load_admin_data_usage_from_backend_cached};
|
||||
use super::storage_api::storage::{
|
||||
@@ -67,6 +68,23 @@ impl HealLifecycleExpiryContext {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct HealStorageObjectResult {
|
||||
pub item: HealResultItem,
|
||||
pub error: Option<Error>,
|
||||
pub receipt: Option<HealObjectReceipt>,
|
||||
}
|
||||
|
||||
impl From<(HealResultItem, Option<Error>)> for HealStorageObjectResult {
|
||||
fn from((item, error): (HealResultItem, Option<Error>)) -> Self {
|
||||
Self {
|
||||
item,
|
||||
error,
|
||||
receipt: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const LOG_COMPONENT_HEAL: &str = "heal";
|
||||
const LOG_SUBSYSTEM_STORAGE: &str = "storage";
|
||||
const EVENT_HEAL_STORAGE_OBJECT_IO: &str = "heal_storage_object_io";
|
||||
@@ -374,6 +392,16 @@ pub trait HealStorageAPI: Send + Sync {
|
||||
opts: &HealOpts,
|
||||
) -> Result<(HealResultItem, Option<Error>)>;
|
||||
|
||||
async fn heal_object_with_receipt(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
version_id: Option<&str>,
|
||||
opts: &HealOpts,
|
||||
) -> Result<HealStorageObjectResult> {
|
||||
self.heal_object(bucket, object, version_id, opts).await.map(Into::into)
|
||||
}
|
||||
|
||||
/// Heal bucket using ecstore
|
||||
async fn heal_bucket(&self, bucket: &str, opts: &HealOpts) -> Result<HealResultItem>;
|
||||
|
||||
@@ -1062,6 +1090,50 @@ impl HealStorageAPI for ECStoreHealStorage {
|
||||
}
|
||||
}
|
||||
|
||||
async fn heal_object_with_receipt(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
version_id: Option<&str>,
|
||||
opts: &HealOpts,
|
||||
) -> Result<HealStorageObjectResult> {
|
||||
let (item, error) = self.heal_object(bucket, object, version_id, opts).await?;
|
||||
let receipt = if error.is_none() && !opts.dry_run {
|
||||
let ok_drive_state = DriveState::Ok.to_string();
|
||||
let all_after_drives_ok = item.after.drives.iter().all(|drive| drive.state == ok_drive_state);
|
||||
match (
|
||||
self.ecstore.bucket_incarnation_id(bucket).await,
|
||||
item.drives_reported(),
|
||||
item.drives_healed(),
|
||||
all_after_drives_ok,
|
||||
) {
|
||||
(Ok(bucket_incarnation_id), Some(_), Some(drives_healed), true) => {
|
||||
let disposition = if drives_healed > 0 {
|
||||
HealObjectDisposition::Repaired
|
||||
} else {
|
||||
HealObjectDisposition::VerifiedHealthy
|
||||
};
|
||||
Some(HealObjectReceipt {
|
||||
identity: HealObjectIdentity {
|
||||
kind: HealObjectKind::Object,
|
||||
bucket: bucket.to_string(),
|
||||
object: object.to_string(),
|
||||
version_id: version_id.map(ToOwned::to_owned),
|
||||
bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
pool_index: opts.pool,
|
||||
set_index: opts.set,
|
||||
},
|
||||
disposition,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(HealStorageObjectResult { item, error, receipt })
|
||||
}
|
||||
|
||||
async fn heal_bucket(&self, bucket: &str, opts: &HealOpts) -> Result<HealResultItem> {
|
||||
debug!(
|
||||
target: "rustfs::heal::storage",
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::heal::{
|
||||
erasure_healer::target_outcomes_complete,
|
||||
outcome::{
|
||||
HealAbortReason, HealDeferredReason, HealFailureClass, HealObjectDisposition, HealObjectIdentity, HealObjectKind,
|
||||
HealObjectOutcome, HealTaskOutcome,
|
||||
HealObjectOutcome, HealObjectReceipt, HealTaskOutcome,
|
||||
},
|
||||
progress::HealProgress,
|
||||
resume::{
|
||||
@@ -592,6 +592,26 @@ impl HealTask {
|
||||
Some(self.outcome_identity(bucket, object, version, self.options.pool_index, self.options.set_index))
|
||||
}
|
||||
|
||||
pub(super) async fn record_verified_storage_receipt(
|
||||
&self,
|
||||
expected: HealObjectIdentity,
|
||||
receipt: Option<HealObjectReceipt>,
|
||||
) -> bool {
|
||||
let Some(receipt) = receipt else {
|
||||
return false;
|
||||
};
|
||||
if !receipt.verified_for(&expected) {
|
||||
return false;
|
||||
}
|
||||
let mut outcome = self.outcome.write().await;
|
||||
outcome.record(HealObjectOutcome {
|
||||
identity: receipt.identity,
|
||||
disposition: receipt.disposition,
|
||||
detail: None,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
async fn record_deferred_object(&self, reason: HealDeferredReason) {
|
||||
if let Some(identity) = self.single_object_identity() {
|
||||
let mut outcome = self.outcome.write().await;
|
||||
|
||||
@@ -163,7 +163,7 @@ impl HealTask {
|
||||
set: self.options.set_index,
|
||||
};
|
||||
|
||||
let heal_fut = self.storage.heal_object(bucket, object, version_id, &heal_opts);
|
||||
let heal_fut = self.storage.heal_object_with_receipt(bucket, object, version_id, &heal_opts);
|
||||
let heal_result = if self.source == HealRequestSource::ReadRepair {
|
||||
let result = heal_fut.await;
|
||||
if self.cancel_token.is_cancelled() {
|
||||
@@ -176,7 +176,9 @@ impl HealTask {
|
||||
};
|
||||
|
||||
match heal_result {
|
||||
Ok((result, error)) => {
|
||||
Ok(storage_result) => {
|
||||
let result = storage_result.item;
|
||||
let error = storage_result.error;
|
||||
if let Some(e) = error {
|
||||
if self.skip_dangling_delete_grace_error(bucket, object, &e).await {
|
||||
return Ok(());
|
||||
@@ -264,6 +266,10 @@ impl HealTask {
|
||||
let mut progress = self.progress.write().await;
|
||||
progress.update_object_progress(1, 1, 0, 0, object_size);
|
||||
}
|
||||
let expected_identity =
|
||||
self.outcome_identity(bucket, object, version_id, self.options.pool_index, self.options.set_index);
|
||||
self.record_verified_storage_receipt(expected_identity, storage_result.receipt)
|
||||
.await;
|
||||
self.record_result_item(result).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
use super::super::{DiskOption, DiskStore, Endpoint, new_disk};
|
||||
use super::*;
|
||||
use crate::heal::storage::HealStorageObjectResult;
|
||||
|
||||
mod deferred_retry;
|
||||
|
||||
@@ -1048,6 +1049,7 @@ struct MockStorage {
|
||||
object_exists_by_name: Mutex<HashMap<String, MockObjectExists>>,
|
||||
heal_object_outcome: Mutex<Option<MockHealObjectOutcome>>,
|
||||
heal_object_outcomes: Mutex<HashMap<String, VecDeque<MockHealObjectOutcome>>>,
|
||||
heal_object_receipts: Mutex<HashMap<String, VecDeque<HealObjectReceipt>>>,
|
||||
format_no_heal_required: Mutex<bool>,
|
||||
format_error: Mutex<Option<Error>>,
|
||||
global_format_calls: Mutex<u32>,
|
||||
@@ -1151,6 +1153,90 @@ async fn execute_emits_heal_trace_task_state() {
|
||||
assert_eq!(trace_attr_string(&completed, "state").as_deref(), Some("completed"));
|
||||
}
|
||||
|
||||
fn object_receipt(object: &str, version_id: Option<&str>, disposition: HealObjectDisposition) -> HealObjectReceipt {
|
||||
HealObjectReceipt {
|
||||
identity: HealObjectIdentity {
|
||||
kind: HealObjectKind::Object,
|
||||
bucket: "bucket-a".to_string(),
|
||||
object: object.to_string(),
|
||||
version_id: version_id.map(ToOwned::to_owned),
|
||||
bucket_incarnation_id: Some(Uuid::new_v4()),
|
||||
pool_index: None,
|
||||
set_index: None,
|
||||
},
|
||||
disposition,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn object_heal_records_matching_positive_storage_receipt() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
heal_object_receipts: Mutex::new(HashMap::from([(
|
||||
"object-a".to_string(),
|
||||
VecDeque::from([object_receipt("object-a", Some("version-a"), HealObjectDisposition::Repaired)]),
|
||||
)])),
|
||||
..Default::default()
|
||||
});
|
||||
let task = HealTask::from_request(
|
||||
HealRequest::object("bucket-a".to_string(), "object-a".to_string(), Some("version-a".to_string())),
|
||||
storage,
|
||||
);
|
||||
|
||||
task.execute().await.expect("mock object heal should complete");
|
||||
|
||||
let outcome = task.get_outcome().await;
|
||||
assert_eq!(outcome.counters.healed, 1);
|
||||
assert_eq!(outcome.counters.unknown, 0);
|
||||
let object = outcome.objects.front().expect("positive receipt should be recorded");
|
||||
assert_eq!(object.identity.object, "object-a");
|
||||
assert_eq!(object.identity.version_id.as_deref(), Some("version-a"));
|
||||
assert!(object.identity.bucket_incarnation_id.is_some());
|
||||
assert_eq!(object.disposition, HealObjectDisposition::Repaired);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn object_heal_rejects_mismatched_or_legacy_storage_receipts() {
|
||||
let storage = Arc::new(MockStorage {
|
||||
heal_object_receipts: Mutex::new(HashMap::from([(
|
||||
"object-a".to_string(),
|
||||
VecDeque::from([object_receipt(
|
||||
"object-a",
|
||||
Some("old-version"),
|
||||
HealObjectDisposition::Repaired,
|
||||
)]),
|
||||
)])),
|
||||
..Default::default()
|
||||
});
|
||||
let task = HealTask::from_request(
|
||||
HealRequest::object("bucket-a".to_string(), "object-a".to_string(), Some("version-a".to_string())),
|
||||
storage,
|
||||
);
|
||||
|
||||
task.execute()
|
||||
.await
|
||||
.expect("a mismatched receipt must not fail the legacy heal result");
|
||||
let outcome = task.get_outcome().await;
|
||||
assert_eq!(outcome.counters.healed, 0);
|
||||
assert_eq!(outcome.counters.unknown, 1);
|
||||
assert_eq!(
|
||||
outcome
|
||||
.objects
|
||||
.front()
|
||||
.expect("legacy fallback should be recorded")
|
||||
.disposition,
|
||||
HealObjectDisposition::Unknown
|
||||
);
|
||||
|
||||
let legacy = HealTask::from_request(
|
||||
HealRequest::object("bucket-a".to_string(), "object-b".to_string(), None),
|
||||
Arc::new(MockStorage::default()),
|
||||
);
|
||||
legacy.execute().await.expect("legacy mock object heal should complete");
|
||||
let legacy_outcome = legacy.get_outcome().await;
|
||||
assert_eq!(legacy_outcome.counters.healed, 0);
|
||||
assert_eq!(legacy_outcome.counters.unknown, 1);
|
||||
}
|
||||
|
||||
async fn recv_trace_task_state(trace: &mut TraceSubscription, task_id: &str, state: &str) -> TraceEvent {
|
||||
for _ in 0..32 {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), trace.recv())
|
||||
@@ -1408,6 +1494,23 @@ impl HealStorageAPI for MockStorage {
|
||||
))
|
||||
}
|
||||
|
||||
async fn heal_object_with_receipt(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
version_id: Option<&str>,
|
||||
opts: &HealOpts,
|
||||
) -> Result<HealStorageObjectResult> {
|
||||
let (item, error) = self.heal_object(bucket, object, version_id, opts).await?;
|
||||
let receipt = self
|
||||
.heal_object_receipts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_mut(object)
|
||||
.and_then(VecDeque::pop_front);
|
||||
Ok(HealStorageObjectResult { item, error, receipt })
|
||||
}
|
||||
|
||||
async fn heal_bucket(&self, bucket: &str, opts: &HealOpts) -> Result<HealResultItem> {
|
||||
self.bucket_heal_calls.lock().unwrap().push(bucket.to_string());
|
||||
self.bucket_heal_opts.lock().unwrap().push(*opts);
|
||||
|
||||
@@ -19,7 +19,8 @@ pub mod heal;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use heal::{
|
||||
HealManager, HealOperationsSnapshot, HealOptions, HealPriority, HealPriorityCounts, HealRequest, HealSourceCounts, HealType,
|
||||
HealAdmissionTelemetry, HealManager, HealOperationsSnapshot, HealOptions, HealPriority, HealPriorityCounts, HealRequest,
|
||||
HealSourceCounts, HealType,
|
||||
channel::HealChannelProcessor,
|
||||
progress::{HealProgress, aggregate_heal_progress},
|
||||
resume::{ReplacementRecoveryRecord, ReplacementRecoveryState, ResumeUtils},
|
||||
|
||||
@@ -29,7 +29,17 @@ use rustfs_heal::heal::{
|
||||
storage::{ECStoreHealStorage, HealStorageAPI},
|
||||
};
|
||||
use serial_test::serial;
|
||||
use std::{path::Path, process::Command, sync::Arc, time::Duration};
|
||||
#[cfg(unix)]
|
||||
use std::{
|
||||
fs::{File, OpenOptions},
|
||||
io::Write,
|
||||
};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
mod storage_api;
|
||||
|
||||
@@ -110,6 +120,48 @@ fn journal_record(kind: u8, bucket: &str, object: &str, version: Option<[u8; 16]
|
||||
body
|
||||
}
|
||||
|
||||
fn scoped_journal_record(
|
||||
kind: u8,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
version: Option<[u8; 16]>,
|
||||
attempts: u8,
|
||||
pool_index: u32,
|
||||
set_index: u32,
|
||||
) -> Vec<u8> {
|
||||
let mut body = vec![1u8, 2, kind, attempts];
|
||||
body.extend_from_slice(&1_700_000_000_000u64.to_le_bytes());
|
||||
match version {
|
||||
Some(bytes) => {
|
||||
body.push(1);
|
||||
body.extend_from_slice(&bytes);
|
||||
}
|
||||
None => body.push(0),
|
||||
}
|
||||
body.extend_from_slice(&pool_index.to_le_bytes());
|
||||
body.extend_from_slice(&set_index.to_le_bytes());
|
||||
body.extend_from_slice(
|
||||
&u32::try_from(bucket.len())
|
||||
.expect("fixture bucket length must fit journal format")
|
||||
.to_le_bytes(),
|
||||
);
|
||||
body.extend_from_slice(
|
||||
&u32::try_from(object.len())
|
||||
.expect("fixture object length must fit journal format")
|
||||
.to_le_bytes(),
|
||||
);
|
||||
body.extend_from_slice(bucket.as_bytes());
|
||||
body.extend_from_slice(object.as_bytes());
|
||||
let mut hasher = crc_fast::Digest::new(crc_fast::CrcAlgorithm::Crc32IsoHdlc);
|
||||
hasher.update(&body);
|
||||
body.extend_from_slice(
|
||||
&u32::try_from(hasher.finalize())
|
||||
.expect("CRC32 must fit the journal checksum field")
|
||||
.to_le_bytes(),
|
||||
);
|
||||
body
|
||||
}
|
||||
|
||||
fn write_journal_path_to_disks(disk_paths: &[std::path::PathBuf], relative_path: &str, data: &[u8]) {
|
||||
for path in disk_paths {
|
||||
let journal = path.join(META_BUCKET).join(relative_path);
|
||||
@@ -118,6 +170,27 @@ fn write_journal_path_to_disks(disk_paths: &[std::path::PathBuf], relative_path:
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_journal_path_to_disks_synced(disk_paths: &[std::path::PathBuf], relative_path: &str, data: &[u8]) {
|
||||
for path in disk_paths {
|
||||
let journal = path.join(META_BUCKET).join(relative_path);
|
||||
let parent = journal.parent().expect("journal parent");
|
||||
std::fs::create_dir_all(parent).expect("create journal dir");
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&journal)
|
||||
.expect("open synced journal fixture");
|
||||
file.write_all(data).expect("write synced journal fixture");
|
||||
file.sync_all().expect("sync journal fixture");
|
||||
File::open(parent)
|
||||
.expect("open journal parent for sync")
|
||||
.sync_all()
|
||||
.expect("sync journal parent");
|
||||
}
|
||||
}
|
||||
|
||||
fn write_journal_to_disks(disk_paths: &[std::path::PathBuf], data: &[u8]) {
|
||||
write_journal_path_to_disks(disk_paths, JOURNAL_REL, data);
|
||||
}
|
||||
@@ -128,6 +201,12 @@ fn journal_exists_on_all_disks(disk_paths: &[std::path::PathBuf], relative_path:
|
||||
.all(|path| Path::new(path).join(META_BUCKET).join(relative_path).exists())
|
||||
}
|
||||
|
||||
fn journal_matches_on_all_disks(disk_paths: &[PathBuf], relative_path: &str, expected: &[u8]) -> bool {
|
||||
disk_paths
|
||||
.iter()
|
||||
.all(|path| std::fs::read(path.join(META_BUCKET).join(relative_path)).is_ok_and(|actual| actual == expected))
|
||||
}
|
||||
|
||||
async fn wait_until<F, Fut>(deadline: Duration, mut probe: F) -> bool
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
@@ -259,6 +338,72 @@ async fn authoritative_journal_is_not_merged_with_legacy_mirror() {
|
||||
!Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()
|
||||
&& !Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()
|
||||
}));
|
||||
|
||||
let scoped_v2 = scoped_journal_record(1, "scoped-v2-bucket", "scoped-v2-object", None, 0, 3, 7);
|
||||
let stale_legacy = journal_record(1, "stale-legacy-bucket", "stale-legacy-object", None, 0);
|
||||
write_journal_path_to_disks(&disk_paths, SCOPED_JOURNAL_REL, &scoped_v2);
|
||||
write_journal_path_to_disks(&disk_paths, JOURNAL_REL, &stale_legacy);
|
||||
assert_eq!(
|
||||
mrf_queue::replay_journal_once(&manager).await,
|
||||
1,
|
||||
"a scoped v2 authoritative epoch must not be merged with a stale v1 legacy mirror"
|
||||
);
|
||||
assert_eq!(
|
||||
manager.operations_snapshot().await.queued_by_source.mrf,
|
||||
3,
|
||||
"only the three authoritative/scoped-only epochs should have reached the manager"
|
||||
);
|
||||
assert!(disk_paths.iter().all(|path| {
|
||||
!Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()
|
||||
&& !Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()
|
||||
}));
|
||||
}
|
||||
|
||||
/// The authoritative journal carries the full replay responsibility identity.
|
||||
/// A stale legacy mirror must not collapse same-object records that differ by
|
||||
/// kind or erasure-set scope after restart.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn authoritative_journal_replay_preserves_kind_and_scope_identity() {
|
||||
let (disk_paths, storage) = heal_env().await;
|
||||
register_local_disks(&disk_paths, "mrf-authoritative-identity-test").await;
|
||||
|
||||
let mut authoritative = scoped_journal_record(3, "identity-bucket", "same-object", None, 0, 3, 7);
|
||||
authoritative.extend(scoped_journal_record(3, "identity-bucket", "same-object", None, 0, 3, 8));
|
||||
authoritative.extend(journal_record(2, "identity-bucket", "same-object", None, 0));
|
||||
authoritative.extend(journal_record(1, "identity-bucket", "same-object", Some([4u8; 16]), 0));
|
||||
let stale_legacy = journal_record(3, "identity-bucket", "stale-legacy-object", None, 0);
|
||||
write_journal_path_to_disks(&disk_paths, SCOPED_JOURNAL_REL, &authoritative);
|
||||
write_journal_path_to_disks(&disk_paths, JOURNAL_REL, &stale_legacy);
|
||||
|
||||
let manager = make_manager(storage);
|
||||
let replayed = mrf_queue::replay_journal_once(&manager).await;
|
||||
assert_eq!(
|
||||
replayed, 4,
|
||||
"all authoritative kind/scope identities must decode before manager admission"
|
||||
);
|
||||
|
||||
let snapshot = manager.operations_snapshot().await;
|
||||
assert_eq!(
|
||||
snapshot.queued_by_source.mrf, 4,
|
||||
"same-object MRF replay must retain distinct kind and scope responsibilities"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.queued_by_priority.normal, 2,
|
||||
"the two scoped partial-write records must remain independently queued"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.queued_by_priority.high, 1,
|
||||
"metadata corruption must not merge with object repair responsibility"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.queued_by_priority.urgent, 1,
|
||||
"decode-failure repair must not merge with object repair responsibility"
|
||||
);
|
||||
assert!(disk_paths.iter().all(|path| {
|
||||
!Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()
|
||||
&& !Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()
|
||||
}));
|
||||
}
|
||||
|
||||
/// If replay reaches a full heal-manager queue, the old journal remains the
|
||||
@@ -348,6 +493,135 @@ fn mrf_journal_child_process_fixture() {
|
||||
std::process::exit(77);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mrf_successor_flush_child_process_fixture() {
|
||||
let Ok(root) = std::env::var("RUSTFS_MRF_SUCCESSOR_FLUSH_CHILD_ROOT") else {
|
||||
return;
|
||||
};
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("child runtime should build");
|
||||
runtime.block_on(async {
|
||||
let (disk_paths, storage) = heal_env_at(Some(Path::new(&root))).await;
|
||||
register_local_disks(&disk_paths, "mrf-successor-flush-child").await;
|
||||
|
||||
let mut startup = journal_record(1, "successor-bucket", "first-object", None, 0);
|
||||
startup.extend(journal_record(1, "successor-bucket", "second-object", None, 0));
|
||||
write_journal_path_to_disks(&disk_paths, SCOPED_JOURNAL_REL, &startup);
|
||||
write_journal_path_to_disks(&disk_paths, JOURNAL_REL, &startup);
|
||||
|
||||
let manager = Arc::new(HealManager::new(
|
||||
storage,
|
||||
Some(HealConfig {
|
||||
queue_size: 1,
|
||||
heal_interval: Duration::from_secs(3600),
|
||||
enable_auto_heal: false,
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
mrf_queue::spawn_mrf_consumer(manager.clone());
|
||||
let expected_successor = journal_record(1, "successor-bucket", "second-object", None, 2);
|
||||
let flushed = wait_until(Duration::from_secs(10), || async {
|
||||
manager.operations_snapshot().await.queued_by_source.mrf == 1
|
||||
&& journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &expected_successor)
|
||||
&& journal_matches_on_all_disks(&disk_paths, JOURNAL_REL, &expected_successor)
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
flushed,
|
||||
"child process must publish the pending successor snapshot before the delete phase"
|
||||
);
|
||||
});
|
||||
std::process::exit(78);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn mrf_successor_flush_waiting_child_process_fixture() {
|
||||
let Ok(root) = std::env::var("RUSTFS_MRF_SUCCESSOR_KILL_CHILD_ROOT") else {
|
||||
return;
|
||||
};
|
||||
let ready_path = std::env::var("RUSTFS_MRF_SUCCESSOR_KILL_READY")
|
||||
.map(PathBuf::from)
|
||||
.expect("ready marker path should be provided");
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("child runtime should build");
|
||||
runtime.block_on(async {
|
||||
let (disk_paths, storage) = heal_env_at(Some(Path::new(&root))).await;
|
||||
register_local_disks(&disk_paths, "mrf-successor-kill-child").await;
|
||||
|
||||
let mut startup = journal_record(1, "service-kill-bucket", "first-object", None, 0);
|
||||
startup.extend(journal_record(1, "service-kill-bucket", "second-object", None, 0));
|
||||
write_journal_path_to_disks(&disk_paths, SCOPED_JOURNAL_REL, &startup);
|
||||
write_journal_path_to_disks(&disk_paths, JOURNAL_REL, &startup);
|
||||
|
||||
let manager = Arc::new(HealManager::new(
|
||||
storage,
|
||||
Some(HealConfig {
|
||||
queue_size: 1,
|
||||
heal_interval: Duration::from_secs(3600),
|
||||
enable_auto_heal: false,
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
mrf_queue::spawn_mrf_consumer(manager.clone());
|
||||
let expected_successor = journal_record(1, "service-kill-bucket", "second-object", None, 2);
|
||||
let flushed = wait_until(Duration::from_secs(10), || async {
|
||||
manager.operations_snapshot().await.queued_by_source.mrf == 1
|
||||
&& journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &expected_successor)
|
||||
&& journal_matches_on_all_disks(&disk_paths, JOURNAL_REL, &expected_successor)
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
flushed,
|
||||
"child process must publish the pending successor snapshot before it can be killed"
|
||||
);
|
||||
std::fs::write(&ready_path, b"ready").expect("write ready marker");
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn mrf_authoritative_fsync_waiting_child_process_fixture() {
|
||||
let Ok(root) = std::env::var("RUSTFS_MRF_FSYNC_KILL_CHILD_ROOT") else {
|
||||
return;
|
||||
};
|
||||
let ready_path = std::env::var("RUSTFS_MRF_FSYNC_KILL_READY")
|
||||
.map(PathBuf::from)
|
||||
.expect("ready marker path should be provided");
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("child runtime should build");
|
||||
runtime.block_on(async {
|
||||
let (disk_paths, _storage) = heal_env_at(Some(Path::new(&root))).await;
|
||||
register_local_disks(&disk_paths, "mrf-fsync-kill-child").await;
|
||||
|
||||
let mut startup = journal_record(1, "fsync-kill-bucket", "first-object", None, 0);
|
||||
startup.extend(journal_record(1, "fsync-kill-bucket", "second-object", None, 0));
|
||||
write_journal_path_to_disks(&disk_paths, SCOPED_JOURNAL_REL, &startup);
|
||||
write_journal_path_to_disks(&disk_paths, JOURNAL_REL, &startup);
|
||||
|
||||
let successor = journal_record(1, "fsync-kill-bucket", "second-object", None, 2);
|
||||
write_journal_path_to_disks_synced(&disk_paths, SCOPED_JOURNAL_REL, &successor);
|
||||
assert!(
|
||||
journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &successor)
|
||||
&& journal_matches_on_all_disks(&disk_paths, JOURNAL_REL, &startup),
|
||||
"child process must reach the canonical-fsync/stale-legacy boundary"
|
||||
);
|
||||
std::fs::write(&ready_path, b"ready").expect("write ready marker");
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A journal published by a different OS process must remain a durable anchor
|
||||
/// when the restarted process can only admit a prefix of the replayed intents.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
@@ -390,3 +664,159 @@ async fn journal_replay_retains_child_process_anchor_when_manager_is_full() {
|
||||
"replay must retain the child-published journal until a successor snapshot can replace it"
|
||||
);
|
||||
}
|
||||
|
||||
/// If a process crashes after flushing a smaller successor snapshot but before
|
||||
/// deleting the startup anchor, the restarted process must replay the
|
||||
/// successor tail rather than losing it or merging it with stale records.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
async fn journal_replay_survives_successor_flush_before_delete() {
|
||||
let temp_dir = tempfile::tempdir().expect("successor-flush MRF root");
|
||||
let status = Command::new(std::env::current_exe().expect("test binary path"))
|
||||
.arg("mrf_successor_flush_child_process_fixture")
|
||||
.arg("--exact")
|
||||
.arg("--nocapture")
|
||||
.env("RUSTFS_MRF_SUCCESSOR_FLUSH_CHILD_ROOT", temp_dir.path())
|
||||
.status()
|
||||
.expect("child MRF successor fixture should start");
|
||||
assert_eq!(status.code(), Some(78), "child process did not reach the successor flush boundary");
|
||||
|
||||
let (disk_paths, storage) = heal_env_at(Some(temp_dir.path())).await;
|
||||
let expected_successor = journal_record(1, "successor-bucket", "second-object", None, 2);
|
||||
assert!(
|
||||
journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &expected_successor),
|
||||
"restarted process must see the pending successor snapshot"
|
||||
);
|
||||
|
||||
let restarted = make_manager(storage);
|
||||
let replayed = mrf_queue::replay_journal_once(&restarted).await;
|
||||
assert_eq!(replayed, 1, "restart after successor flush must replay only the still-pending tail");
|
||||
assert_eq!(
|
||||
restarted.operations_snapshot().await.queued_by_source.mrf,
|
||||
1,
|
||||
"the successor tail must be accepted after restart"
|
||||
);
|
||||
assert!(
|
||||
disk_paths.iter().all(|path| {
|
||||
!Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()
|
||||
&& !Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()
|
||||
}),
|
||||
"a fully consumed successor snapshot may be deleted after restart replay"
|
||||
);
|
||||
}
|
||||
|
||||
/// A service-style hard kill after successor flush must be equivalent to a
|
||||
/// crash at the flush-before-delete boundary: restart may replay the smaller
|
||||
/// successor snapshot, but must not lose or merge stale startup records.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
#[cfg(unix)]
|
||||
async fn journal_replay_survives_service_kill_after_successor_flush() {
|
||||
let temp_dir = tempfile::tempdir().expect("successor-kill MRF root");
|
||||
let ready = temp_dir.path().join("successor-flushed.ready");
|
||||
let mut child = Command::new(std::env::current_exe().expect("test binary path"))
|
||||
.arg("mrf_successor_flush_waiting_child_process_fixture")
|
||||
.arg("--exact")
|
||||
.arg("--nocapture")
|
||||
.env("RUSTFS_MRF_SUCCESSOR_KILL_CHILD_ROOT", temp_dir.path())
|
||||
.env("RUSTFS_MRF_SUCCESSOR_KILL_READY", &ready)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("child MRF successor fixture should start");
|
||||
let ready_seen = wait_until(Duration::from_secs(10), || {
|
||||
let ready = ready.clone();
|
||||
async move { ready.exists() }
|
||||
})
|
||||
.await;
|
||||
assert!(ready_seen, "child process did not reach the successor flush boundary");
|
||||
child.kill().expect("kill child fixture");
|
||||
let status = child.wait().expect("wait for killed child fixture");
|
||||
assert!(!status.success(), "child fixture must be terminated instead of exiting cleanly");
|
||||
|
||||
let (disk_paths, storage) = heal_env_at(Some(temp_dir.path())).await;
|
||||
let expected_successor = journal_record(1, "service-kill-bucket", "second-object", None, 2);
|
||||
assert!(
|
||||
journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &expected_successor),
|
||||
"restarted process must see the successor snapshot produced before the kill"
|
||||
);
|
||||
|
||||
let restarted = make_manager(storage);
|
||||
let replayed = mrf_queue::replay_journal_once(&restarted).await;
|
||||
assert_eq!(replayed, 1, "restart after service kill must replay only the still-pending tail");
|
||||
assert_eq!(
|
||||
restarted.operations_snapshot().await.queued_by_source.mrf,
|
||||
1,
|
||||
"the successor tail must be accepted after service kill restart"
|
||||
);
|
||||
assert!(
|
||||
disk_paths.iter().all(|path| {
|
||||
!Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()
|
||||
&& !Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()
|
||||
}),
|
||||
"a fully consumed successor snapshot may be deleted after service-kill restart replay"
|
||||
);
|
||||
}
|
||||
|
||||
/// A hard kill between the authoritative successor fsync and the legacy mirror
|
||||
/// rewrite must prefer the canonical successor tail over the stale legacy
|
||||
/// startup epoch. This models the mixed-version boundary conservatively: new
|
||||
/// readers must not merge epochs, while the old mirror remains crash-visible.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[serial]
|
||||
#[cfg(unix)]
|
||||
async fn journal_replay_survives_sigkill_after_authoritative_successor_fsync_before_legacy_mirror() {
|
||||
let temp_dir = tempfile::tempdir().expect("fsync-kill MRF root");
|
||||
let ready = temp_dir.path().join("authoritative-synced.ready");
|
||||
let mut child = Command::new(std::env::current_exe().expect("test binary path"))
|
||||
.arg("mrf_authoritative_fsync_waiting_child_process_fixture")
|
||||
.arg("--exact")
|
||||
.arg("--nocapture")
|
||||
.env("RUSTFS_MRF_FSYNC_KILL_CHILD_ROOT", temp_dir.path())
|
||||
.env("RUSTFS_MRF_FSYNC_KILL_READY", &ready)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("child MRF fsync fixture should start");
|
||||
let ready_seen = wait_until(Duration::from_secs(10), || {
|
||||
let ready = ready.clone();
|
||||
async move { ready.exists() }
|
||||
})
|
||||
.await;
|
||||
assert!(ready_seen, "child process did not reach the authoritative fsync boundary");
|
||||
child.kill().expect("kill child fixture");
|
||||
let status = child.wait().expect("wait for killed child fixture");
|
||||
assert!(!status.success(), "child fixture must be terminated instead of exiting cleanly");
|
||||
|
||||
let (disk_paths, storage) = heal_env_at(Some(temp_dir.path())).await;
|
||||
let expected_successor = journal_record(1, "fsync-kill-bucket", "second-object", None, 2);
|
||||
let stale_startup = {
|
||||
let mut startup = journal_record(1, "fsync-kill-bucket", "first-object", None, 0);
|
||||
startup.extend(journal_record(1, "fsync-kill-bucket", "second-object", None, 0));
|
||||
startup
|
||||
};
|
||||
assert!(
|
||||
journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &expected_successor),
|
||||
"restarted process must see the fsynced authoritative successor"
|
||||
);
|
||||
assert!(
|
||||
journal_matches_on_all_disks(&disk_paths, JOURNAL_REL, &stale_startup),
|
||||
"legacy mirror intentionally remains at the stale startup epoch"
|
||||
);
|
||||
|
||||
let restarted = make_manager(storage);
|
||||
let replayed = mrf_queue::replay_journal_once(&restarted).await;
|
||||
assert_eq!(replayed, 1, "new reader must replay only the authoritative successor tail");
|
||||
assert_eq!(
|
||||
restarted.operations_snapshot().await.queued_by_source.mrf,
|
||||
1,
|
||||
"the successor tail must be accepted after the fsync-boundary restart"
|
||||
);
|
||||
assert!(
|
||||
disk_paths.iter().all(|path| {
|
||||
!Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()
|
||||
&& !Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()
|
||||
}),
|
||||
"a fully consumed authoritative successor may clean both epochs after restart replay"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use rustfs_io_metrics::{MetricsCollector, PerformanceMetrics, record_get_object_request_started};
|
||||
use rustfs_io_metrics::{record_s3_op, s3_http_metrics::S3HttpRequestGuard};
|
||||
use rustfs_s3_ops::S3Operation;
|
||||
use std::hint::black_box;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -8,6 +10,17 @@ fn bench_record_get_object_request_started(c: &mut Criterion) {
|
||||
c.bench_function("record_get_object_request_started", |b| b.iter(record_get_object_request_started));
|
||||
}
|
||||
|
||||
fn bench_s3_http_outcomes(c: &mut Criterion) {
|
||||
c.bench_function("s3_http_handler_counter", |b| b.iter(|| record_s3_op(black_box(S3Operation::PutObject))));
|
||||
c.bench_function("s3_http_handler_counter_with_outcome", |b| {
|
||||
b.iter(|| {
|
||||
let mut request = S3HttpRequestGuard::new(black_box("PUT"));
|
||||
request.in_scope(|| record_s3_op(black_box(S3Operation::PutObject)));
|
||||
request.response(black_box(200));
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_update_concurrent_requests(c: &mut Criterion) {
|
||||
let metrics = PerformanceMetrics::new();
|
||||
|
||||
@@ -37,6 +50,7 @@ fn bench_metrics_collector_record_io_operation(c: &mut Criterion) {
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_record_get_object_request_started,
|
||||
bench_s3_http_outcomes,
|
||||
bench_update_concurrent_requests,
|
||||
bench_metrics_collector_record_io_operation
|
||||
);
|
||||
|
||||
@@ -241,10 +241,7 @@ impl AdaptiveTTL {
|
||||
// 1. Item is cold (low access count)
|
||||
// 2. Age is significant (> 50% of TTL)
|
||||
// 3. No recent accesses
|
||||
if access_count <= self.cold_threshold && age > current_ttl / 2 {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
access_count <= self.cold_threshold && age > current_ttl / 2
|
||||
}
|
||||
|
||||
/// Calculate priority score for an item.
|
||||
|
||||
@@ -226,6 +226,7 @@ pub mod lock_metrics;
|
||||
pub mod performance;
|
||||
pub mod process_lock_metrics;
|
||||
pub mod s3_api_metrics;
|
||||
pub mod s3_http_metrics;
|
||||
pub mod sampler;
|
||||
pub mod system_path_metrics;
|
||||
pub mod timeout_metrics;
|
||||
|
||||
@@ -43,6 +43,7 @@ fn s3_op_counters() -> &'static [AtomicU64] {
|
||||
/// This mirrors MinIO, which never labels its default operation counters with
|
||||
/// bucket. The `op` dimension is bounded (<= 122 variants).
|
||||
pub fn record_s3_op(op: S3Operation) {
|
||||
crate::s3_http_metrics::observe_s3_http_operation(op);
|
||||
if let Some(counter) = s3_op_counters().get(op.metric_index()) {
|
||||
counter.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
// Copyright 2024 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.
|
||||
|
||||
//! External S3 HTTP outcomes, including requests rejected before S3 dispatch.
|
||||
//! Admin snapshots and metric exporters share these counters. The older
|
||||
//! operation counter counts handler entries and is not an HTTP denominator.
|
||||
|
||||
use rustfs_s3_ops::S3Operation;
|
||||
use std::cell::Cell;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{LazyLock, OnceLock};
|
||||
|
||||
const METRIC: &str = "rustfs_s3_http_requests_total";
|
||||
const METHODS: [&str; 10] = [
|
||||
"GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS", "PATCH", "CONNECT", "TRACE", "OTHER",
|
||||
];
|
||||
const OUTCOMES: [&str; 8] = ["1xx", "2xx", "3xx", "4xx", "5xx", "unknown", "service_error", "cancelled"];
|
||||
const UNKNOWN_OPERATION: usize = S3Operation::ALL.len();
|
||||
static COUNTERS: LazyLock<HttpOutcomeCounters> = LazyLock::new(HttpOutcomeCounters::new);
|
||||
|
||||
tokio::task_local! {
|
||||
static CURRENT_OPERATION: Cell<usize>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct S3HttpMetricSnapshot {
|
||||
pub method: &'static str,
|
||||
pub operation: &'static str,
|
||||
pub outcome: &'static str,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
struct OutcomeCounter {
|
||||
total: AtomicU64,
|
||||
exported: OnceLock<metrics::Counter>,
|
||||
}
|
||||
|
||||
struct HttpOutcomeCounters(Box<[OutcomeCounter]>);
|
||||
|
||||
impl HttpOutcomeCounters {
|
||||
fn new() -> Self {
|
||||
Self(
|
||||
std::iter::repeat_with(|| OutcomeCounter {
|
||||
total: AtomicU64::new(0),
|
||||
exported: OnceLock::new(),
|
||||
})
|
||||
.take(METHODS.len() * (UNKNOWN_OPERATION + 1) * OUTCOMES.len())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn record(&self, method: usize, operation: usize, outcome: usize) {
|
||||
let counter = &self.0[(method * (UNKNOWN_OPERATION + 1) + operation) * OUTCOMES.len() + outcome];
|
||||
counter.total.fetch_add(1, Ordering::Relaxed);
|
||||
counter
|
||||
.exported
|
||||
.get_or_init(|| {
|
||||
counter!(METRIC, "method" => METHODS[method], "op" => operation_label(operation), "outcome" => OUTCOMES[outcome])
|
||||
})
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> Vec<S3HttpMetricSnapshot> {
|
||||
// Individual series are monotonic; a concurrent snapshot is not a
|
||||
// transaction across series. Rates must compare consecutive samples.
|
||||
self.0
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, counter)| {
|
||||
let total = counter.total.load(Ordering::Relaxed);
|
||||
(total != 0).then(|| S3HttpMetricSnapshot {
|
||||
method: METHODS[index / OUTCOMES.len() / (UNKNOWN_OPERATION + 1)],
|
||||
operation: operation_label(index / OUTCOMES.len() % (UNKNOWN_OPERATION + 1)),
|
||||
outcome: OUTCOMES[index % OUTCOMES.len()],
|
||||
total,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn operation_label(index: usize) -> &'static str {
|
||||
S3Operation::ALL.get(index).map_or("unknown", |op| op.as_str())
|
||||
}
|
||||
|
||||
pub(crate) fn observe_s3_http_operation(op: S3Operation) {
|
||||
let _ = CURRENT_OPERATION.try_with(|current| {
|
||||
// Internal operations must not overwrite the external request's first
|
||||
// dispatched operation. No task-local scope means non-HTTP work.
|
||||
if current.get() == UNKNOWN_OPERATION {
|
||||
current.set(op.metric_index());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// An external request is counted exactly once: at response headers, at a
|
||||
/// service error, or when its future is dropped before producing a response.
|
||||
/// Body-stream failures after headers use the existing streaming metrics.
|
||||
pub struct S3HttpRequestGuard {
|
||||
method: usize,
|
||||
operation: usize,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl S3HttpRequestGuard {
|
||||
pub fn is_active() -> bool {
|
||||
CURRENT_OPERATION.try_with(|_| ()).is_ok()
|
||||
}
|
||||
|
||||
pub fn new(method: &str) -> Self {
|
||||
Self {
|
||||
method: METHODS.iter().position(|known| *known == method).unwrap_or(METHODS.len() - 1),
|
||||
operation: UNKNOWN_OPERATION,
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attribute existing operation instrumentation without changing S3
|
||||
/// handlers or propagating metric labels through storage/RPC contracts.
|
||||
pub fn in_scope<T>(&mut self, f: impl FnOnce() -> T) -> T {
|
||||
CURRENT_OPERATION.sync_scope(Cell::new(self.operation), || {
|
||||
let result = f();
|
||||
self.operation = CURRENT_OPERATION.with(Cell::get);
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
pub fn response(&mut self, status: u16) {
|
||||
let outcome = match status {
|
||||
100..=599 => usize::from(status / 100 - 1),
|
||||
_ => 5,
|
||||
};
|
||||
self.finish(outcome);
|
||||
}
|
||||
|
||||
pub fn service_error(&mut self) {
|
||||
self.finish(6);
|
||||
}
|
||||
|
||||
fn finish(&mut self, outcome: usize) {
|
||||
if !self.finished {
|
||||
COUNTERS.record(self.method, self.operation, outcome);
|
||||
self.finished = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for S3HttpRequestGuard {
|
||||
fn drop(&mut self) {
|
||||
self.finish(7);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn s3_http_metrics_snapshot() -> Vec<S3HttpMetricSnapshot> {
|
||||
COUNTERS.snapshot()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use metrics::with_local_recorder;
|
||||
use metrics_util::debugging::DebuggingRecorder;
|
||||
|
||||
#[test]
|
||||
fn outcome_counters_distinguish_partial_and_complete_write_failure() {
|
||||
let counters = HttpOutcomeCounters::new();
|
||||
let recorder = DebuggingRecorder::new();
|
||||
with_local_recorder(&recorder, || {
|
||||
for _ in 0..99 {
|
||||
counters.record(1, S3Operation::PutObject.metric_index(), 1);
|
||||
}
|
||||
counters.record(1, S3Operation::PutObject.metric_index(), 4);
|
||||
for _ in 0..100 {
|
||||
counters.record(1, UNKNOWN_OPERATION, 4);
|
||||
}
|
||||
});
|
||||
let snapshot = counters.snapshot();
|
||||
assert_eq!(snapshot.iter().map(|series| series.total).sum::<u64>(), 200);
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.iter()
|
||||
.find(|s| s.operation == S3Operation::PutObject.as_str() && s.outcome == "5xx")
|
||||
.expect("write failure")
|
||||
.total,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.iter()
|
||||
.find(|s| s.operation == "unknown")
|
||||
.expect("pre-dispatch failures")
|
||||
.total,
|
||||
100
|
||||
);
|
||||
let exported = recorder.snapshotter().snapshot().into_vec();
|
||||
assert_eq!(exported.len(), 3);
|
||||
for (key, _, _, _) in exported {
|
||||
let labels: Vec<_> = key.key().labels().map(|label| label.key()).collect();
|
||||
assert_eq!(labels, ["method", "op", "outcome"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_guard_preserves_operation_across_polls_and_finishes_once() {
|
||||
let totals = || {
|
||||
s3_http_metrics_snapshot()
|
||||
.into_iter()
|
||||
.filter(|series| series.method == "CONNECT")
|
||||
.map(|series| ((series.operation, series.outcome), series.total))
|
||||
.collect::<std::collections::BTreeMap<_, _>>()
|
||||
};
|
||||
let before = totals();
|
||||
let mut request = S3HttpRequestGuard::new("CONNECT");
|
||||
request.in_scope(|| observe_s3_http_operation(S3Operation::PutObject));
|
||||
request.in_scope(|| {
|
||||
assert!(S3HttpRequestGuard::is_active());
|
||||
observe_s3_http_operation(S3Operation::GetObject);
|
||||
});
|
||||
assert!(!S3HttpRequestGuard::is_active());
|
||||
request.response(204);
|
||||
request.response(503);
|
||||
request.service_error();
|
||||
drop(request);
|
||||
let after = totals();
|
||||
let key = (S3Operation::PutObject.as_str(), "2xx");
|
||||
assert_eq!(after[&key] - before.get(&key).copied().unwrap_or_default(), 1);
|
||||
assert_eq!(after.values().sum::<u64>() - before.values().sum::<u64>(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_operation_is_scoped_and_first_dispatch_wins() {
|
||||
let mut request = S3HttpRequestGuard::new("PUT");
|
||||
request.in_scope(|| {
|
||||
observe_s3_http_operation(S3Operation::PutObject);
|
||||
observe_s3_http_operation(S3Operation::GetObject);
|
||||
});
|
||||
assert_eq!(request.operation, S3Operation::PutObject.metric_index());
|
||||
observe_s3_http_operation(S3Operation::GetObject);
|
||||
let other = S3HttpRequestGuard::new("attacker-controlled-method");
|
||||
assert_eq!(other.method, METHODS.len() - 1);
|
||||
assert_eq!(other.operation, UNKNOWN_OPERATION);
|
||||
}
|
||||
}
|
||||
@@ -188,6 +188,30 @@ pub enum BackendByte {
|
||||
pub struct StorageInfo {
|
||||
pub disks: Vec<Disk>,
|
||||
pub backend: BackendInfo,
|
||||
/// Missing observations from older nodes are unknown, never proof of health.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub observations: Vec<StorageInfoObservation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StorageInfoProbeStatus {
|
||||
Succeeded,
|
||||
Failed,
|
||||
#[default]
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct StorageInfoObservation {
|
||||
pub endpoint: String,
|
||||
pub status: StorageInfoProbeStatus,
|
||||
pub cached: bool,
|
||||
pub last_success_unix_millis: Option<u64>,
|
||||
pub snapshot_age_seconds: Option<u64>,
|
||||
pub error_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
@@ -879,6 +903,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
backend: BackendInfo::default(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(storage_info.disks.len(), 2);
|
||||
@@ -886,6 +911,40 @@ mod tests {
|
||||
assert_eq!(storage_info.disks[1].state, "offline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_info_observation_is_additive_and_unknown_for_old_peers() {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct LegacyStorageInfo {
|
||||
disks: Vec<Disk>,
|
||||
backend: BackendInfo,
|
||||
}
|
||||
let old = LegacyStorageInfo {
|
||||
disks: Vec::new(),
|
||||
backend: BackendInfo::default(),
|
||||
};
|
||||
let encoded = rmp_serde::to_vec_named(&old).expect("legacy map");
|
||||
let decoded: StorageInfo = rmp_serde::from_slice(&encoded).expect("old peer response");
|
||||
assert!(decoded.observations.is_empty());
|
||||
let mut new = decoded;
|
||||
new.observations.push(StorageInfoObservation {
|
||||
endpoint: "node2:9000".into(),
|
||||
status: StorageInfoProbeStatus::Failed,
|
||||
cached: true,
|
||||
last_success_unix_millis: Some(1_700_000_000_000),
|
||||
snapshot_age_seconds: Some(5),
|
||||
error_code: Some("Timeout".into()),
|
||||
});
|
||||
let encoded = rmp_serde::to_vec_named(&new).expect("new map");
|
||||
let legacy: LegacyStorageInfo = rmp_serde::from_slice(&encoded).expect("old reader ignores new fields");
|
||||
assert!(legacy.disks.is_empty());
|
||||
let roundtrip: StorageInfo = rmp_serde::from_slice(&encoded).expect("new reader preserves observation");
|
||||
assert_eq!(roundtrip.observations, new.observations);
|
||||
let unknown: StorageInfoObservation =
|
||||
serde_json::from_str(r#"{"endpoint":"node2","status":"future_state"}"#).expect("future state remains unknown");
|
||||
assert_eq!(unknown.status, StorageInfoProbeStatus::Unknown);
|
||||
assert_eq!(unknown.snapshot_age_seconds, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backend_disks_new() {
|
||||
let backend_disks = BackendDisks::new();
|
||||
@@ -1391,6 +1450,7 @@ mod tests {
|
||||
let storage_info = StorageInfo {
|
||||
disks: vec![],
|
||||
backend: BackendInfo::default(),
|
||||
..Default::default()
|
||||
};
|
||||
let backend_info = BackendInfo::default();
|
||||
let mem_stats = MemStats::default();
|
||||
|
||||
@@ -997,10 +997,56 @@ pub struct Metrics {
|
||||
pub cpu: Option<CPUMetrics>,
|
||||
#[serde(rename = "rpc", skip_serializing_if = "Option::is_none")]
|
||||
pub rpc: Option<RPCMetrics>,
|
||||
/// Absent means this node did not report HTTP outcomes, not zero traffic.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub http: Option<HttpMetrics>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct HttpMetrics {
|
||||
#[serde(rename = "collected")]
|
||||
pub collected_at: Timestamp,
|
||||
pub requests: Vec<HttpRequestMetric>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct HttpRequestMetric {
|
||||
pub method: String,
|
||||
pub operation: String,
|
||||
pub outcome: String,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
impl HttpMetrics {
|
||||
fn merge(&mut self, other: &Self) {
|
||||
self.collected_at = self.collected_at.max(other.collected_at);
|
||||
let mut totals = std::collections::BTreeMap::new();
|
||||
for series in self.requests.drain(..).chain(other.requests.iter().cloned()) {
|
||||
let total = totals
|
||||
.entry((series.method, series.operation, series.outcome))
|
||||
.or_insert(0_u64);
|
||||
*total = total.saturating_add(series.total);
|
||||
}
|
||||
self.requests = totals
|
||||
.into_iter()
|
||||
.map(|((method, operation, outcome), total)| HttpRequestMetric {
|
||||
method,
|
||||
operation,
|
||||
outcome,
|
||||
total,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
impl Metrics {
|
||||
pub fn merge(&mut self, other: &Self) {
|
||||
if let Some(http) = &other.http {
|
||||
match &mut self.http {
|
||||
Some(existing) => existing.merge(http),
|
||||
None => self.http = Some(http.clone()),
|
||||
}
|
||||
}
|
||||
if let Some(scanner) = other.scanner.as_ref() {
|
||||
match self.scanner {
|
||||
Some(ref mut s_scanner) => s_scanner.merge(scanner),
|
||||
@@ -1473,6 +1519,70 @@ mod tests {
|
||||
Timestamp::constant(1_700_000_000, 123_456_000)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_metrics_merge_preserves_outcomes_and_missing_node_support() {
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct LegacyMetrics {
|
||||
rpc: Option<RPCMetrics>,
|
||||
}
|
||||
let old_map = rmp_serde::to_vec_named(&LegacyMetrics::default()).expect("legacy map");
|
||||
assert!(rmp_serde::from_slice::<Metrics>(&old_map).expect("new reader").http.is_none());
|
||||
let missing: Metrics = serde_json::from_str("{}").expect("old node metrics");
|
||||
assert!(missing.http.is_none());
|
||||
let mut combined = RealtimeMetrics::default();
|
||||
for (host, successes, failures) in [("node1", 99, 1), ("node2", 0, 100)] {
|
||||
let metrics = Metrics {
|
||||
http: Some(HttpMetrics {
|
||||
collected_at: fixed_timestamp(),
|
||||
requests: [("2xx", successes), ("5xx", failures)]
|
||||
.into_iter()
|
||||
.map(|(outcome, total)| HttpRequestMetric {
|
||||
method: "PUT".into(),
|
||||
operation: "s3:PutObject".into(),
|
||||
outcome: outcome.into(),
|
||||
total,
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let encoded = rmp_serde::to_vec_named(&metrics).expect("peer metric map");
|
||||
let old_reader: LegacyMetrics = rmp_serde::from_slice(&encoded).expect("old reader ignores HTTP field");
|
||||
assert!(old_reader.rpc.is_none());
|
||||
let decoded: Metrics = rmp_serde::from_slice(&encoded).expect("peer metric roundtrip");
|
||||
combined.merge(RealtimeMetrics {
|
||||
aggregated: decoded,
|
||||
by_host: HashMap::from([(host.into(), metrics)]),
|
||||
hosts: vec![host.into()],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let aggregate = combined.aggregated.http.as_ref().expect("HTTP support");
|
||||
assert_eq!(
|
||||
aggregate
|
||||
.requests
|
||||
.iter()
|
||||
.find(|s| s.outcome == "5xx")
|
||||
.expect("failures")
|
||||
.total,
|
||||
101
|
||||
);
|
||||
assert_eq!(aggregate.requests.iter().map(|s| s.total).sum::<u64>(), 200);
|
||||
assert_eq!(combined.by_host["node2"].http.as_ref().expect("node2").requests[1].total, 100);
|
||||
combined.aggregated.merge(&missing);
|
||||
assert_eq!(
|
||||
combined
|
||||
.aggregated
|
||||
.http
|
||||
.as_ref()
|
||||
.expect("supported peers remain")
|
||||
.requests
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_metrics_timestamps_serialize_as_rfc3339_utc() {
|
||||
let timestamp = fixed_timestamp();
|
||||
|
||||
@@ -122,6 +122,10 @@ pub trait ReplicationConfigurationExt {
|
||||
fn has_active_rules(&self, prefix: &str, recursive: bool) -> bool;
|
||||
fn filter_target_arns(&self, obj: &ObjectOpts) -> Vec<String>;
|
||||
fn filter_force_delete_target_arns(&self, prefix: &str) -> Vec<String>;
|
||||
/// Every target ARN the configuration still names, whatever the rule's
|
||||
/// status, prefix or filter: the set a pending replication delete may
|
||||
/// still be owed to. A target outside it was removed by the operator.
|
||||
fn configured_target_arns(&self) -> HashSet<String>;
|
||||
fn filter_target_replication_decisions(&self, obj: &ObjectOpts) -> Vec<(String, bool)> {
|
||||
self.filter_target_arns(obj)
|
||||
.into_iter()
|
||||
@@ -772,6 +776,19 @@ impl ReplicationConfigurationExt for ReplicationConfiguration {
|
||||
}
|
||||
|
||||
/// Filter target ARNs and return a slice of the distinct values in the config
|
||||
fn configured_target_arns(&self) -> HashSet<String> {
|
||||
let role = self.role.trim();
|
||||
if !role.is_empty() {
|
||||
return HashSet::from([role.to_string()]);
|
||||
}
|
||||
self.rules
|
||||
.iter()
|
||||
.map(|rule| rule.destination.bucket.trim())
|
||||
.filter(|arn| !arn.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn filter_target_arns(&self, obj: &ObjectOpts) -> Vec<String> {
|
||||
let role = self.role.trim();
|
||||
if !role.is_empty() {
|
||||
|
||||
@@ -438,6 +438,12 @@ pub struct ReplicatedTargetInfo {
|
||||
/// Version the target assigned to the delete marker it just created.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target_delete_marker_version_id: Option<String>,
|
||||
/// Version the target assigned to this object version when it differs
|
||||
/// from the source id (a target that mints its own ids). Persisted as the
|
||||
/// per-target ledger every later version-addressed mutation resolves
|
||||
/// through; `None` on targets that adopt the source id.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target_version_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ReplicatedTargetInfo {
|
||||
|
||||
@@ -38,6 +38,7 @@ use rustfs_utils::path::{SLASH_SEPARATOR, path_join_buf};
|
||||
use tokio::time::{Duration, Instant, sleep, timeout};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::raw_page_index::{RawEnumerationPageIndex, RawEnumerationPageOwnerStatus};
|
||||
use crate::storage_api::owner::HTTPPreconditions;
|
||||
use crate::{
|
||||
BUCKET_META_PREFIX, EcstoreError as Error, EcstoreResult as StorageResult, RUSTFS_META_BUCKET, ReplicationConfig,
|
||||
@@ -535,6 +536,10 @@ pub struct DataUsageEntryInfo {
|
||||
pub name: String,
|
||||
pub parent: String,
|
||||
pub entry: DataUsageEntry,
|
||||
/// Durable bucket incarnation that produced this bucket root. Missing
|
||||
/// values are legacy/unproven and must not authorize cold-bucket reuse.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bucket_incarnation: Option<uuid::Uuid>,
|
||||
/// Registry generation used to classify this root entry. Older remote
|
||||
/// workers omit it; callers must reject that result when a frozen cycle
|
||||
/// requires generation fencing.
|
||||
@@ -601,6 +606,8 @@ pub struct DataUsageCacheInfo {
|
||||
pub scan_checkpoint: Option<DataUsageScanCheckpoint>,
|
||||
#[serde(default)]
|
||||
pub scan_raw_enumeration_cursor: Option<DataUsageRawEnumerationCursor>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub scan_raw_enumeration_page_index: Option<RawEnumerationPageIndex>,
|
||||
#[serde(default)]
|
||||
pub scan_identity: Option<DataUsageScanIdentity>,
|
||||
#[serde(default)]
|
||||
@@ -650,6 +657,11 @@ pub struct DataUsageCacheInfo {
|
||||
/// structural plan remains reusable across ordinary bucket writes.
|
||||
#[serde(default)]
|
||||
pub scan_execution_digest: Option<DataUsageScanPlanDigest>,
|
||||
/// Durable bucket incarnations captured for a complete set aggregate.
|
||||
/// Missing or nil entries are legacy/unproven and cannot authorize
|
||||
/// skipping an unselected bucket in a later scoped set scan.
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub scan_bucket_incarnations: HashMap<String, uuid::Uuid>,
|
||||
}
|
||||
|
||||
impl Serialize for DataUsageCacheInfo {
|
||||
@@ -661,6 +673,7 @@ impl Serialize for DataUsageCacheInfo {
|
||||
// appended by newer scanner versions during rolling upgrades.
|
||||
let field_count = 16
|
||||
+ usize::from(self.scan_raw_enumeration_cursor.is_some())
|
||||
+ usize::from(self.scan_raw_enumeration_page_index.is_some())
|
||||
+ usize::from(self.scan_identity.is_some())
|
||||
+ usize::from(self.scan_progress.is_some())
|
||||
+ usize::from(self.scan_coverage_receipt.is_some())
|
||||
@@ -672,7 +685,8 @@ impl Serialize for DataUsageCacheInfo {
|
||||
+ usize::from(self.lkg_last_update.is_some())
|
||||
+ usize::from(self.lkg_leader_epoch.is_some())
|
||||
+ usize::from(self.lkg_scan_plan_digest.is_some())
|
||||
+ usize::from(self.scan_execution_digest.is_some());
|
||||
+ usize::from(self.scan_execution_digest.is_some())
|
||||
+ usize::from(!self.scan_bucket_incarnations.is_empty());
|
||||
let mut state = serializer.serialize_map(Some(field_count))?;
|
||||
state.serialize_entry("name", &self.name)?;
|
||||
state.serialize_entry("next_cycle", &self.next_cycle)?;
|
||||
@@ -687,6 +701,9 @@ impl Serialize for DataUsageCacheInfo {
|
||||
if let Some(cursor) = &self.scan_raw_enumeration_cursor {
|
||||
state.serialize_entry("scan_raw_enumeration_cursor", cursor)?;
|
||||
}
|
||||
if let Some(index) = &self.scan_raw_enumeration_page_index {
|
||||
state.serialize_entry("scan_raw_enumeration_page_index", index)?;
|
||||
}
|
||||
if let Some(identity) = self.scan_identity {
|
||||
state.serialize_entry("scan_identity", &identity)?;
|
||||
}
|
||||
@@ -729,6 +746,9 @@ impl Serialize for DataUsageCacheInfo {
|
||||
if let Some(scan_execution_digest) = self.scan_execution_digest {
|
||||
state.serialize_entry("scan_execution_digest", &scan_execution_digest)?;
|
||||
}
|
||||
if !self.scan_bucket_incarnations.is_empty() {
|
||||
state.serialize_entry("scan_bucket_incarnations", &self.scan_bucket_incarnations)?;
|
||||
}
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
@@ -895,6 +915,7 @@ impl DataUsageCache {
|
||||
&& self.info.scan_progress.is_none()
|
||||
&& self.info.scan_checkpoint.is_none()
|
||||
&& self.info.scan_raw_enumeration_cursor.is_none()
|
||||
&& self.info.scan_raw_enumeration_page_index.is_none()
|
||||
&& self.info.scan_resume_after.is_none()
|
||||
&& self.info.scan_coverage_receipt.is_none()
|
||||
&& self.info.scan_plan_digest == Some(scan_plan_digest)
|
||||
@@ -922,12 +943,17 @@ impl DataUsageCache {
|
||||
if self.validated_raw_enumeration_cursor().is_none() {
|
||||
self.info.scan_raw_enumeration_cursor = None;
|
||||
}
|
||||
if self.validated_raw_enumeration_page_index().is_none() {
|
||||
self.info.scan_raw_enumeration_page_index = None;
|
||||
}
|
||||
let cursor_is_valid = (self.info.scan_checkpoint.is_none()
|
||||
&& self.info.scan_raw_enumeration_cursor.is_none()
|
||||
&& self.info.scan_raw_enumeration_page_index.is_none()
|
||||
&& self.info.scan_resume_after.is_none()
|
||||
&& self.info.scan_coverage_receipt.is_none())
|
||||
|| self.validated_scan_frontier().is_some()
|
||||
|| self.info.scan_raw_enumeration_cursor.is_some();
|
||||
|| self.info.scan_raw_enumeration_cursor.is_some()
|
||||
|| self.info.scan_raw_enumeration_page_index.is_some();
|
||||
if !cursor_is_valid {
|
||||
self.info.scan_progress = None;
|
||||
}
|
||||
@@ -949,6 +975,7 @@ impl DataUsageCache {
|
||||
self.info.scan_resume_after = None;
|
||||
self.info.scan_checkpoint = None;
|
||||
self.info.scan_raw_enumeration_cursor = None;
|
||||
self.info.scan_raw_enumeration_page_index = None;
|
||||
self.info.scan_coverage_receipt = None;
|
||||
}
|
||||
// Old readers do not understand coverage sweeps. An absent plan makes
|
||||
@@ -1026,6 +1053,25 @@ impl DataUsageCache {
|
||||
.then_some(cursor)
|
||||
}
|
||||
|
||||
pub(crate) fn validated_raw_enumeration_page_index(&self) -> Option<&RawEnumerationPageIndex> {
|
||||
let index = self.info.scan_raw_enumeration_page_index.as_ref()?;
|
||||
if self.info.scan_progress.is_none()
|
||||
|| !self.info.scan_identity.is_some_and(|identity| identity.is_valid())
|
||||
|| self.info.source.is_none()
|
||||
|| index.committed_entries().is_err()
|
||||
|| index.indexed_entries().is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let parent = match index.status() {
|
||||
RawEnumerationPageOwnerStatus::Unsupported => return None,
|
||||
RawEnumerationPageOwnerStatus::Building { parent, .. } | RawEnumerationPageOwnerStatus::Ready { parent, .. } => {
|
||||
parent
|
||||
}
|
||||
};
|
||||
path_is_in_bucket_scope(&self.info.name, &parent).then_some(index)
|
||||
}
|
||||
|
||||
/// Seal only the frontier supplied by completed traversal, never a restored cursor.
|
||||
pub(crate) fn seal_scan_frontier(&mut self, frontier: Option<&str>) -> Result<(), serde_json::Error> {
|
||||
if self.info.scan_progress.is_none() {
|
||||
|
||||
@@ -1179,6 +1179,7 @@ fn test_new_data_usage_cache_msgpack_round_trips_and_supports_old_reader() {
|
||||
7,
|
||||
[7; 32],
|
||||
)),
|
||||
scan_raw_enumeration_page_index: Some(raw_page_index_fixture("bucket/prefix", &["entry-a"], false)),
|
||||
snapshot_complete: true,
|
||||
scan_plan_digest: Some(TEST_PLAN_DIGEST),
|
||||
scan_execution_digest: Some(DataUsageScanPlanDigest([42; 32])),
|
||||
@@ -1207,6 +1208,7 @@ fn test_new_data_usage_cache_msgpack_round_trips_and_supports_old_reader() {
|
||||
.map(|cursor| cursor.last_entry.as_deref()),
|
||||
Some(Some("last-object"))
|
||||
);
|
||||
assert!(current.info.scan_raw_enumeration_page_index.is_some());
|
||||
assert!(current.info.snapshot_complete);
|
||||
assert_eq!(current.info.scan_plan_digest, Some(TEST_PLAN_DIGEST));
|
||||
assert_eq!(current.info.scan_execution_digest, Some(DataUsageScanPlanDigest([42; 32])));
|
||||
@@ -1260,6 +1262,24 @@ fn cache_with_raw_cursor(cursor: DataUsageRawEnumerationCursor) -> DataUsageCach
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_page_index_fixture(parent: &str, entries: &[&str], complete: bool) -> RawEnumerationPageIndex {
|
||||
let mut index = RawEnumerationPageIndex::new(parent, 2).expect("raw page index should initialize");
|
||||
let generation = index.generation().expect("raw page index should expose generation");
|
||||
let outcome = if complete {
|
||||
index.ingest_owner_entries(entries.iter().map(|entry| (*entry).to_string()), entries.len().max(1), generation)
|
||||
} else {
|
||||
index.ingest_partial_owner_entries(entries.iter().map(|entry| (*entry).to_string()), entries.len().max(1), generation)
|
||||
}
|
||||
.expect("raw page index fixture should ingest entries");
|
||||
if outcome.ready_to_commit {
|
||||
let generation = index.generation().expect("raw page index should expose commit generation");
|
||||
index
|
||||
.commit_building_page(generation)
|
||||
.expect("raw page index fixture should commit ready page");
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_enumeration_cursor_validation_requires_bucket_identity_and_bounded_marker() {
|
||||
let valid = DataUsageRawEnumerationCursor::new("bucket/raw".to_string(), Some("entry-001".to_string()), 1, [8; 32]);
|
||||
@@ -1346,6 +1366,44 @@ fn prepare_bucket_checkpoint_preserves_only_valid_raw_enumeration_cursor() {
|
||||
assert!(cache.info.scan_progress.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_bucket_checkpoint_preserves_only_valid_raw_page_index() {
|
||||
let identity = valid_scan_identity();
|
||||
let source = DataUsageCacheSource::new(1, 2);
|
||||
let page_index = raw_page_index_fixture("bucket/raw", &["entry-001"], false);
|
||||
let mut cache = DataUsageCache {
|
||||
info: DataUsageCacheInfo {
|
||||
name: "bucket".to_string(),
|
||||
leader_epoch: 1,
|
||||
source: Some(source),
|
||||
cache_key_format: DATA_USAGE_CACHE_KEY_FORMAT,
|
||||
scan_identity: Some(identity),
|
||||
tier_registry_generation: Some(9),
|
||||
scan_progress: Some(DataUsageScanProgress {
|
||||
started_plan: TEST_PLAN_DIGEST,
|
||||
requested_plan: TEST_PLAN_DIGEST,
|
||||
}),
|
||||
scan_raw_enumeration_page_index: Some(page_index.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
cache.prepare_bucket_checkpoint("bucket", 1, 1, source, TEST_PLAN_DIGEST, identity),
|
||||
DataUsageCachePrepareOutcome::Reused
|
||||
);
|
||||
assert_eq!(cache.info.scan_raw_enumeration_page_index, Some(page_index));
|
||||
|
||||
let invalid = raw_page_index_fixture("other/raw", &["entry-001"], false);
|
||||
cache.info.scan_raw_enumeration_page_index = Some(invalid);
|
||||
assert_eq!(
|
||||
cache.prepare_bucket_checkpoint("bucket", 1, 1, source, TEST_PLAN_DIGEST, identity),
|
||||
DataUsageCachePrepareOutcome::Reused
|
||||
);
|
||||
assert!(cache.info.scan_raw_enumeration_page_index.is_none());
|
||||
assert!(cache.info.scan_progress.is_some());
|
||||
}
|
||||
|
||||
/// Deterministic, fully populated cache used to pin the persisted
|
||||
/// `.usage-cache.bin` wire bytes. Every map/set holds at most one element
|
||||
/// so the map-encoded `marshal_msg` output is byte-stable.
|
||||
|
||||
@@ -60,6 +60,7 @@ use uuid::Uuid;
|
||||
pub mod data_usage_define;
|
||||
pub mod error;
|
||||
pub mod prefix_usage;
|
||||
pub mod raw_page_index;
|
||||
mod remote_scanner;
|
||||
pub mod runtime_config;
|
||||
pub mod scanner;
|
||||
@@ -68,6 +69,8 @@ pub mod scanner_folder;
|
||||
#[cfg(test)]
|
||||
mod scanner_heal_admission_baseline;
|
||||
pub mod scanner_io;
|
||||
#[doc(hidden)]
|
||||
pub mod segment_invalidation;
|
||||
pub mod sleeper;
|
||||
pub(crate) mod storage_api;
|
||||
mod workload_admission;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -195,6 +195,7 @@ fn test_usage(bucket: &str, objects: usize) -> DataUsageEntryInfo {
|
||||
name: bucket.to_string(),
|
||||
parent: crate::DATA_USAGE_ROOT.to_string(),
|
||||
entry,
|
||||
bucket_incarnation: Some(Uuid::from_u128(7)),
|
||||
tier_registry_generation: Some(0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7609,6 +7609,81 @@ async fn scanner_cycle_confirms_lost_remote_ack_from_activity_snapshot() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_cycle_confirms_lost_scoped_ack_only_after_same_instance_clean_activity() {
|
||||
let acknowledgement = ScannerDirtyUsageAcknowledgement {
|
||||
host: "node-2".to_string(),
|
||||
instance_id: "epoch-a".to_string(),
|
||||
kind: ScannerDirtyUsageAcknowledgementKind::Scoped {
|
||||
owner_id: Uuid::from_u128(0x11111111111111111111111111111111).to_string(),
|
||||
entries: vec![crate::storage_api::EcstoreScannerScopedDirtyUsageAckEntry {
|
||||
bucket: "photos".to_string(),
|
||||
bucket_incarnation: Uuid::from_u128(0x22222222222222222222222222222222),
|
||||
generation: 5,
|
||||
}],
|
||||
},
|
||||
};
|
||||
let attempted_send = Arc::new(AtomicBool::new(false));
|
||||
let attempted_send_for_ack = Arc::clone(&attempted_send);
|
||||
let cleared_activity = BTreeMap::from([("node-2".to_string(), scanner_node_activity("epoch-a", 7, 3))]);
|
||||
let response_lost = remote_dirty_usage_acknowledgement_pending(
|
||||
8,
|
||||
1,
|
||||
std::slice::from_ref(&acknowledgement),
|
||||
async move {
|
||||
attempted_send_for_ack.store(true, Ordering::SeqCst);
|
||||
Err::<bool, _>(std::io::Error::other("scoped ACK transport failed after peer send"))
|
||||
},
|
||||
|| async { Ok(cleared_activity) },
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
attempted_send.load(Ordering::SeqCst),
|
||||
"the confirmation oracle must run only after the scoped ACK send was attempted"
|
||||
);
|
||||
assert_eq!(
|
||||
scanner_cycle_outcome_with_pending_maintenance(ScannerCycleOutcome::Completed, response_lost),
|
||||
ScannerCycleOutcome::Completed,
|
||||
"a same-instance clean activity snapshot confirms a lost scoped ACK response"
|
||||
);
|
||||
|
||||
let restarted_activity = BTreeMap::from([("node-2".to_string(), scanner_node_activity("epoch-b", 7, 3))]);
|
||||
let peer_restarted = remote_dirty_usage_acknowledgement_pending(
|
||||
8,
|
||||
1,
|
||||
std::slice::from_ref(&acknowledgement),
|
||||
std::future::ready(Err::<bool, _>(std::io::Error::other(
|
||||
"scoped ACK transport failed before peer restart was observed",
|
||||
))),
|
||||
|| async { Ok(restarted_activity) },
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
scanner_cycle_outcome_with_pending_maintenance(ScannerCycleOutcome::Completed, peer_restarted),
|
||||
ScannerCycleOutcome::CompletedWithPendingMaintenance,
|
||||
"a restarted peer cannot prove the scoped ACK reached the old scanner instance"
|
||||
);
|
||||
|
||||
let mut written_activity = scanner_node_activity("epoch-a", 7, 3);
|
||||
written_activity.dirty_usage_generation = 6;
|
||||
written_activity.dirty_usage_pending = true;
|
||||
let concurrent_write = remote_dirty_usage_acknowledgement_pending(
|
||||
8,
|
||||
1,
|
||||
&[acknowledgement],
|
||||
std::future::ready(Err::<bool, _>(std::io::Error::other(
|
||||
"scoped ACK transport failed before a concurrent write was observed",
|
||||
))),
|
||||
|| async { Ok(BTreeMap::from([("node-2".to_string(), written_activity)])) },
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
scanner_cycle_outcome_with_pending_maintenance(ScannerCycleOutcome::Completed, concurrent_write),
|
||||
ScannerCycleOutcome::CompletedWithPendingMaintenance,
|
||||
"a same-instance concurrent write after scoped ACK send keeps maintenance pending"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn finalizing_an_already_durable_enum_without_proof_keeps_dirty_pending() {
|
||||
|
||||
@@ -25,6 +25,7 @@ use crate::data_usage_define::{
|
||||
PendingScannerHealKind, ScannerSizeSummaryExt, SizeReconciliationEntry, SizeSummary, hash_path,
|
||||
};
|
||||
use crate::error::ScannerError;
|
||||
use crate::raw_page_index::{RawEnumerationPageIndex, RawEnumerationPageIndexError};
|
||||
use crate::runtime_config::{
|
||||
scanner_alert_excess_folders, scanner_alert_excess_version_size, scanner_alert_excess_versions, scanner_yield_every_n_objects,
|
||||
};
|
||||
@@ -90,6 +91,8 @@ const DATA_SCANNER_FORCE_COMPACT_AT_FOLDERS: usize = 250_000;
|
||||
const SCANNER_LIST_PATH_RAW_STALL_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const SCANNER_ENTRY_PROGRESS_BATCH: u64 = 32;
|
||||
const SCANNER_ENTRY_PROGRESS_INTERVAL: Duration = Duration::from_secs(30);
|
||||
const SCANNER_RAW_ENUMERATION_PAGE_ENTRY_LIMIT: usize = 128;
|
||||
const SCANNER_RAW_ENUMERATION_PAGE_BUILD_BUDGET: usize = 1;
|
||||
// Erasure data directories contain direct part.N files; keep namespace probes bounded.
|
||||
const ERASURE_DATA_DIR_PROBE_ENTRY_LIMIT: usize = 64;
|
||||
const DEFAULT_HEAL_OBJECT_SELECT_PROB: u32 = 1024;
|
||||
@@ -751,17 +754,34 @@ struct RawEnumerationProgress {
|
||||
last_entry: Option<String>,
|
||||
entries_seen: u64,
|
||||
digest: Sha256,
|
||||
observed_entries: Vec<String>,
|
||||
revalidate_after_entries: usize,
|
||||
page_index: Option<RawEnumerationPageIndex>,
|
||||
}
|
||||
|
||||
impl RawEnumerationProgress {
|
||||
fn new(parent: &str) -> Self {
|
||||
fn new(parent: &str, page_index: Option<RawEnumerationPageIndex>) -> Self {
|
||||
let mut digest = Sha256::new();
|
||||
update_raw_enumeration_digest(&mut digest, b"parent", parent.as_bytes());
|
||||
let mut revalidate_after_entries = 0;
|
||||
let page_index = match page_index {
|
||||
Some(index) => match index.indexed_entries() {
|
||||
Ok(entries) => {
|
||||
revalidate_after_entries = entries.len();
|
||||
Some(index)
|
||||
}
|
||||
Err(_) => None,
|
||||
},
|
||||
None => RawEnumerationPageIndex::new(parent, SCANNER_RAW_ENUMERATION_PAGE_ENTRY_LIMIT).ok(),
|
||||
};
|
||||
Self {
|
||||
parent: parent.to_string(),
|
||||
last_entry: None,
|
||||
entries_seen: 0,
|
||||
digest,
|
||||
observed_entries: Vec::new(),
|
||||
revalidate_after_entries,
|
||||
page_index,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -769,19 +789,63 @@ impl RawEnumerationProgress {
|
||||
update_raw_enumeration_digest(&mut self.digest, b"entry", entry.as_bytes());
|
||||
self.last_entry = Some(entry.to_string());
|
||||
self.entries_seen = self.entries_seen.saturating_add(1);
|
||||
self.observed_entries.push(entry.to_string());
|
||||
if let Some(index) = &mut self.page_index {
|
||||
if self.observed_entries.len() < self.revalidate_after_entries {
|
||||
return;
|
||||
}
|
||||
let result = index
|
||||
.generation()
|
||||
.ok_or(RawEnumerationPageIndexError::Unsupported)
|
||||
.and_then(|generation| {
|
||||
index.ingest_partial_owner_entries(
|
||||
self.observed_entries.clone(),
|
||||
SCANNER_RAW_ENUMERATION_PAGE_BUILD_BUDGET,
|
||||
generation,
|
||||
)
|
||||
});
|
||||
match result {
|
||||
Ok(outcome) if outcome.ready_to_commit => {
|
||||
if let Some(generation) = index.generation()
|
||||
&& index.commit_building_page(generation).is_err()
|
||||
{
|
||||
self.page_index = None;
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => {
|
||||
self.page_index = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn into_cursor(self) -> Option<DataUsageRawEnumerationCursor> {
|
||||
fn cursor(&self) -> Option<DataUsageRawEnumerationCursor> {
|
||||
if self.entries_seen == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(DataUsageRawEnumerationCursor::new(
|
||||
self.parent,
|
||||
self.last_entry,
|
||||
self.parent.clone(),
|
||||
self.last_entry.clone(),
|
||||
self.entries_seen,
|
||||
self.digest.finalize().into(),
|
||||
self.digest.clone().finalize().into(),
|
||||
))
|
||||
}
|
||||
|
||||
fn page_index(&self) -> Option<RawEnumerationPageIndex> {
|
||||
self.page_index.clone().and_then(|mut index| {
|
||||
if let Some(generation) = index.generation()
|
||||
&& matches!(index.status(), crate::raw_page_index::RawEnumerationPageOwnerStatus::Building { .. })
|
||||
&& index.commit_building_page(generation).is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
match index.indexed_entries() {
|
||||
Ok(entries) if !entries.is_empty() => Some(index),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn update_raw_enumeration_digest(digest: &mut Sha256, label: &[u8], value: &[u8]) {
|
||||
@@ -1049,6 +1113,19 @@ impl FolderScanner {
|
||||
if self.old_cache.info.scan_progress.is_none() {
|
||||
return;
|
||||
}
|
||||
let page_index = self
|
||||
.old_cache
|
||||
.validated_raw_enumeration_page_index()
|
||||
.filter(|index| match index.status() {
|
||||
crate::raw_page_index::RawEnumerationPageOwnerStatus::Building {
|
||||
parent: index_parent, ..
|
||||
}
|
||||
| crate::raw_page_index::RawEnumerationPageOwnerStatus::Ready {
|
||||
parent: index_parent, ..
|
||||
} => index_parent == parent,
|
||||
crate::raw_page_index::RawEnumerationPageOwnerStatus::Unsupported => false,
|
||||
})
|
||||
.cloned();
|
||||
if let Some(position) = self
|
||||
.raw_enumeration_progress
|
||||
.iter()
|
||||
@@ -1056,13 +1133,37 @@ impl FolderScanner {
|
||||
{
|
||||
self.raw_enumeration_progress.truncate(position + 1);
|
||||
} else {
|
||||
self.raw_enumeration_progress.push(RawEnumerationProgress::new(parent));
|
||||
self.raw_enumeration_progress
|
||||
.push(RawEnumerationProgress::new(parent, page_index));
|
||||
}
|
||||
if let Some(progress) = self.raw_enumeration_progress.last_mut() {
|
||||
progress.record_entry(entry);
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_enumeration_committed_entry_oracle(&self, parent: &str) -> HashSet<String> {
|
||||
let Some(index) = self.old_cache.validated_raw_enumeration_page_index() else {
|
||||
return HashSet::new();
|
||||
};
|
||||
let generation_matches_parent = match index.status() {
|
||||
crate::raw_page_index::RawEnumerationPageOwnerStatus::Building {
|
||||
generation,
|
||||
parent: index_parent,
|
||||
..
|
||||
}
|
||||
| crate::raw_page_index::RawEnumerationPageOwnerStatus::Ready {
|
||||
generation,
|
||||
parent: index_parent,
|
||||
..
|
||||
} => generation > 0 && index_parent == parent,
|
||||
crate::raw_page_index::RawEnumerationPageOwnerStatus::Unsupported => false,
|
||||
};
|
||||
if !generation_matches_parent {
|
||||
return HashSet::new();
|
||||
}
|
||||
index.committed_entries().unwrap_or_default().into_iter().collect()
|
||||
}
|
||||
|
||||
fn finish_raw_enumeration_parent(&mut self, parent: &str) {
|
||||
self.raw_enumeration_progress.retain(|progress| {
|
||||
progress.parent != parent
|
||||
@@ -1073,11 +1174,11 @@ impl FolderScanner {
|
||||
});
|
||||
}
|
||||
|
||||
fn take_raw_enumeration_cursor(&mut self) -> Option<DataUsageRawEnumerationCursor> {
|
||||
self.raw_enumeration_progress
|
||||
.drain(..)
|
||||
.next()
|
||||
.and_then(RawEnumerationProgress::into_cursor)
|
||||
fn take_raw_enumeration_resume_state(&mut self) -> (Option<DataUsageRawEnumerationCursor>, Option<RawEnumerationPageIndex>) {
|
||||
match self.raw_enumeration_progress.drain(..).next() {
|
||||
Some(progress) => (progress.cursor(), progress.page_index()),
|
||||
None => (None, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn carry_forward_old_children(&mut self, parent_hash: &DataUsageHash, entry: &mut DataUsageEntry) {
|
||||
@@ -1411,6 +1512,7 @@ impl FolderScanner {
|
||||
let mut pending_entry_progress = 0_u64;
|
||||
let mut last_entry_progress = Instant::now();
|
||||
let mut raw_enumeration_complete = false;
|
||||
let raw_enumeration_committed_entries = self.raw_enumeration_committed_entry_oracle(&folder.name);
|
||||
|
||||
loop {
|
||||
let entry = match dir_reader.next_entry().await {
|
||||
@@ -1449,20 +1551,23 @@ impl FolderScanner {
|
||||
}
|
||||
Err(e) => return Err(ScannerError::Io(e)),
|
||||
};
|
||||
#[cfg(test)]
|
||||
tests::enumeration_restart::observe_raw_entry(&dir_path, &entry.file_name(), &self.budget);
|
||||
pending_entry_progress = pending_entry_progress.saturating_add(1);
|
||||
if pending_entry_progress >= SCANNER_ENTRY_PROGRESS_BATCH
|
||||
|| last_entry_progress.elapsed() >= SCANNER_ENTRY_PROGRESS_INTERVAL
|
||||
{
|
||||
self.budget.record_entries_visited(pending_entry_progress);
|
||||
pending_entry_progress = 0;
|
||||
last_entry_progress = Instant::now();
|
||||
}
|
||||
let file_name = entry.file_name().to_string_lossy().to_string();
|
||||
if file_name.is_empty() || file_name == "." || file_name == ".." {
|
||||
continue;
|
||||
}
|
||||
let raw_entry_consumed_by_owner_index = raw_enumeration_committed_entries.contains(&file_name);
|
||||
if !raw_entry_consumed_by_owner_index {
|
||||
#[cfg(test)]
|
||||
tests::enumeration_restart::observe_raw_entry(&dir_path, &entry.file_name(), &self.budget);
|
||||
pending_entry_progress = pending_entry_progress.saturating_add(1);
|
||||
if pending_entry_progress >= SCANNER_ENTRY_PROGRESS_BATCH
|
||||
|| last_entry_progress.elapsed() >= SCANNER_ENTRY_PROGRESS_INTERVAL
|
||||
{
|
||||
self.budget.record_entries_visited(pending_entry_progress);
|
||||
pending_entry_progress = 0;
|
||||
last_entry_progress = Instant::now();
|
||||
}
|
||||
}
|
||||
self.record_raw_enumeration_entry(&folder.name, &file_name);
|
||||
let is_storage_format_entry = file_name == STORAGE_FORMAT_FILE;
|
||||
|
||||
@@ -2686,6 +2791,7 @@ pub(crate) async fn scan_data_folder_scoped(
|
||||
new_cache.info.scan_resume_after = None;
|
||||
new_cache.info.scan_checkpoint = None;
|
||||
new_cache.info.scan_raw_enumeration_cursor = None;
|
||||
new_cache.info.scan_raw_enumeration_page_index = None;
|
||||
new_cache.info.scan_coverage_receipt = None;
|
||||
if had_scan_checkpoint {
|
||||
global_metrics().record_scanner_checkpoint_cleared();
|
||||
@@ -2703,9 +2809,10 @@ pub(crate) async fn scan_data_folder_scoped(
|
||||
let root_hash = hash_path(&cache.info.name);
|
||||
let root_has_progress = data_usage_root_has_progress(&root);
|
||||
let pending_heals_changed = scanner.pending_heals_changed;
|
||||
let raw_enumeration_cursor = scanner.take_raw_enumeration_cursor();
|
||||
let carry_forward_cache =
|
||||
(raw_enumeration_cursor.is_some() && !root_has_progress).then(|| scanner.old_cache.cache.clone());
|
||||
let (raw_enumeration_cursor, raw_enumeration_page_index) = scanner.take_raw_enumeration_resume_state();
|
||||
let carry_forward_cache = ((raw_enumeration_cursor.is_some() || raw_enumeration_page_index.is_some())
|
||||
&& !root_has_progress)
|
||||
.then(|| scanner.old_cache.cache.clone());
|
||||
if root_has_progress {
|
||||
scanner.carry_forward_old_children(&root_hash, &mut root);
|
||||
}
|
||||
@@ -2722,8 +2829,15 @@ pub(crate) async fn scan_data_folder_scoped(
|
||||
new_cache.info.scan_resume_after = None;
|
||||
new_cache.info.scan_coverage_receipt = None;
|
||||
}
|
||||
if raw_enumeration_page_index.is_some() {
|
||||
new_cache.info.scan_raw_enumeration_page_index = raw_enumeration_page_index;
|
||||
new_cache.info.scan_checkpoint = None;
|
||||
new_cache.info.scan_resume_after = None;
|
||||
new_cache.info.scan_coverage_receipt = None;
|
||||
}
|
||||
if partial_cache_is_useful(&root, pending_heals_changed)
|
||||
|| new_cache.info.scan_raw_enumeration_cursor.is_some()
|
||||
|| new_cache.info.scan_raw_enumeration_page_index.is_some()
|
||||
|| !new_cache.info.size_reconciliation.is_empty()
|
||||
{
|
||||
if new_cache.root().is_some() {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
use super::*;
|
||||
#[cfg(test)]
|
||||
use rustfs_filemeta::MetadataResolutionParams;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use sha2::Sha256;
|
||||
|
||||
/// Cached folder information for scanning
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -1036,12 +1036,19 @@ impl ScannerItem {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(replication) = self.replication.clone() else {
|
||||
return;
|
||||
let replication = match self.replication.clone() {
|
||||
Some(replication) => (*replication).clone(),
|
||||
// No active rules or targets, but a purge the bucket still owes
|
||||
// must reach the heal path: the delete worker settles it against
|
||||
// the current configuration (abandoned when the target is gone,
|
||||
// rustfs/backlog#2340) so the hidden version stops blocking
|
||||
// DeleteBucket.
|
||||
None if !oi.version_purge_status.is_empty() => ReplicationConfig::new(None, None),
|
||||
None => return,
|
||||
};
|
||||
|
||||
let done_replication = Metrics::time(Metric::CheckReplication);
|
||||
let replication_result = queue_replication_heal(&oi.bucket, oi.clone(), (*replication).clone(), 0).await;
|
||||
let replication_result = queue_replication_heal(&oi.bucket, oi.clone(), replication, 0).await;
|
||||
done_replication();
|
||||
let roi = replication_result.object_info;
|
||||
record_scanner_replication_admission(global_metrics(), &roi, replication_result.admission);
|
||||
|
||||
@@ -2451,6 +2451,82 @@ async fn scoped_root_scan_reuses_clean_top_level_entries_and_rescans_dirty_entri
|
||||
assert_eq!((bucket.size, bucket.objects), (17, 3));
|
||||
}
|
||||
|
||||
async fn scan_hot_cold_segment_fixture(scoped: bool) -> (DataUsageEntry, Vec<String>) {
|
||||
let (mut scanner, temp_dir) = build_test_scanner().await;
|
||||
let _guard = TestGuard::new(60, 100, &mut scanner, temp_dir.clone());
|
||||
write_test_object_metadata(&temp_dir, "bucket", "cold/object").await;
|
||||
write_test_object_metadata(&temp_dir, "bucket", "hot/object").await;
|
||||
|
||||
scanner.old_cache.info.name = "bucket".to_string();
|
||||
scanner.new_cache.info.name = "bucket".to_string();
|
||||
scanner.update_cache.info.name = "bucket".to_string();
|
||||
if scoped {
|
||||
scanner.old_cache.replace("bucket", "", DataUsageEntry::default());
|
||||
scanner.old_cache.replace(
|
||||
"bucket/cold",
|
||||
"bucket",
|
||||
DataUsageEntry {
|
||||
size: 0,
|
||||
objects: 1,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
scanner.prefix_scan_scope =
|
||||
ScannerBucketPrefixScanScope::from_dirty_top_level_entries(HashSet::from(["hot".to_string()]));
|
||||
}
|
||||
|
||||
let walked = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
scanner.update_current_path = Arc::new({
|
||||
let walked = walked.clone();
|
||||
move |path: &str| {
|
||||
walked.lock().expect("lock observed scanner paths").push(path.to_string());
|
||||
Box::pin(async {})
|
||||
}
|
||||
});
|
||||
|
||||
let folder = CachedFolder {
|
||||
name: "bucket".to_string(),
|
||||
parent: None,
|
||||
object_heal_prob_div: 1,
|
||||
};
|
||||
let mut root = DataUsageEntry::default();
|
||||
scanner
|
||||
.scan_folder(CancellationToken::new(), folder, &mut root)
|
||||
.await
|
||||
.expect("segment fixture scan should finish");
|
||||
let root = scanner
|
||||
.new_cache
|
||||
.size_recursive("bucket")
|
||||
.expect("segment fixture should produce a bucket cache root");
|
||||
let walked = walked.lock().expect("read observed scanner paths").clone();
|
||||
(root, walked)
|
||||
}
|
||||
|
||||
fn walked_path_in(paths: &[String], subtree: &str) -> bool {
|
||||
paths
|
||||
.iter()
|
||||
.any(|path| path == subtree || path.strip_prefix(subtree).is_some_and(|rest| rest.starts_with('/')))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scoped_root_scan_zero_walks_clean_cold_segment_with_full_oracle_equivalence() {
|
||||
let (full, full_walked) = scan_hot_cold_segment_fixture(false).await;
|
||||
let (scoped, scoped_walked) = scan_hot_cold_segment_fixture(true).await;
|
||||
|
||||
assert_eq!((scoped.size, scoped.objects), (full.size, full.objects));
|
||||
assert_eq!((scoped.size, scoped.objects), (0, 2));
|
||||
assert!(
|
||||
walked_path_in(&full_walked, "bucket/cold"),
|
||||
"the full oracle must prove the cold segment would be walked without scoped reuse"
|
||||
);
|
||||
assert!(walked_path_in(&scoped_walked, "bucket/hot"), "the dirty hot segment must still be walked");
|
||||
assert!(
|
||||
!walked_path_in(&scoped_walked, "bucket/cold"),
|
||||
"a clean cold segment must be copied from the durable baseline without walker callbacks"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scoped_root_scan_preserves_erasure_health_walks() {
|
||||
@@ -2722,6 +2798,27 @@ async fn test_scan_data_folder_returns_raw_cursor_on_enumeration_cancel_without_
|
||||
assert!(raw_cursor.last_entry.is_some());
|
||||
assert_ne!(raw_cursor.page_digest, [0; 32]);
|
||||
assert_eq!(partial_cache.validated_raw_enumeration_cursor(), Some(raw_cursor));
|
||||
let page_index = partial_cache
|
||||
.validated_raw_enumeration_page_index()
|
||||
.expect("raw enumeration cancellation should persist a validated page index");
|
||||
assert_eq!(
|
||||
page_index
|
||||
.indexed_entries()
|
||||
.expect("persisted raw page index entries should validate")
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
page_index
|
||||
.committed_entries()
|
||||
.expect("checkpointed raw page should validate as committed coverage"),
|
||||
vec![
|
||||
raw_cursor
|
||||
.last_entry
|
||||
.clone()
|
||||
.expect("checkpointed page should include the observed entry")
|
||||
]
|
||||
);
|
||||
assert_eq!(budget.reason(), Some(crate::scanner_budget::ScannerCycleBudgetReason::Runtime));
|
||||
}
|
||||
|
||||
@@ -3370,3 +3467,74 @@ fn test_should_log_failed_object_samples_after_initial_limit() {
|
||||
assert!(!should_log_failed_object(SCANNER_FAILED_OBJECT_LOG_EVERY + 1));
|
||||
assert!(should_log_failed_object(SCANNER_FAILED_OBJECT_LOG_EVERY * 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_enumeration_progress_waits_for_resume_index_floor_before_revalidation() {
|
||||
let mut index = RawEnumerationPageIndex::new("bucket", 2).expect("raw page index should initialize");
|
||||
let generation = index.generation().expect("raw page index should expose generation");
|
||||
index
|
||||
.ingest_partial_owner_entries(["entry-a".to_string(), "entry-b".to_string()], 2, generation)
|
||||
.expect("initial entries should build a page");
|
||||
let generation = index.generation().expect("raw page index should expose next generation");
|
||||
index.commit_building_page(generation).expect("initial page should commit");
|
||||
|
||||
let mut progress = RawEnumerationProgress::new("bucket", Some(index));
|
||||
progress.record_entry("entry-b");
|
||||
assert!(
|
||||
progress.page_index.is_some(),
|
||||
"resume index must not be dropped before the current run observes the old index floor"
|
||||
);
|
||||
|
||||
progress.record_entry("entry-a");
|
||||
assert!(
|
||||
progress.page_index.is_some(),
|
||||
"same entry identity after the observation floor should keep the resume index"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_enumeration_progress_checkpoint_commits_budgeted_page_for_oracle() {
|
||||
let mut progress = RawEnumerationProgress::new("bucket", None);
|
||||
progress.record_entry("entry-b");
|
||||
|
||||
let page_index = progress
|
||||
.page_index()
|
||||
.expect("checkpointed raw progress should retain a committed owner page");
|
||||
let page_entries = page_index
|
||||
.committed_entries()
|
||||
.expect("checkpointed owner page should validate by digest");
|
||||
assert_eq!(page_entries, vec!["entry-b".to_string()]);
|
||||
assert_eq!(
|
||||
page_index
|
||||
.indexed_entries()
|
||||
.expect("checkpointed owner index should validate"),
|
||||
page_entries
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_enumeration_progress_retains_resume_index_until_unordered_entries_reappear() {
|
||||
let mut index = RawEnumerationPageIndex::new("bucket", 2).expect("raw page index should initialize");
|
||||
let generation = index.generation().expect("raw page index should expose generation");
|
||||
index
|
||||
.ingest_partial_owner_entries(["entry-a".to_string(), "entry-b".to_string()], 2, generation)
|
||||
.expect("initial entries should build a page");
|
||||
let generation = index.generation().expect("raw page index should expose next generation");
|
||||
index.commit_building_page(generation).expect("initial page should commit");
|
||||
|
||||
let mut progress = RawEnumerationProgress::new("bucket", Some(index));
|
||||
progress.record_entry("entry-a");
|
||||
assert!(progress.page_index.is_some());
|
||||
|
||||
progress.record_entry("entry-c");
|
||||
assert!(
|
||||
progress.page_index.is_some(),
|
||||
"partial observations must not discard the resume index before an unordered old entry can reappear"
|
||||
);
|
||||
|
||||
progress.record_entry("entry-b");
|
||||
assert!(
|
||||
progress.page_index.is_some(),
|
||||
"same source identity should keep the resume index even when read_dir order changes"
|
||||
);
|
||||
}
|
||||
|
||||
+184
-42
@@ -1,59 +1,204 @@
|
||||
//! Fixture-only range diagnostics. No result is supplied to a scan selector.
|
||||
|
||||
use super::*;
|
||||
use crate::segment_invalidation::{
|
||||
MAX_SEGMENT_INVALIDATION_BYTES, MAX_SEGMENT_INVALIDATION_ENTRIES, SegmentInvalidationDomain, SegmentInvalidationEnvelope,
|
||||
SegmentInvalidationError, SegmentInvalidationProducer, SegmentInvalidationProof, admit_segment_invalidation,
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
const MAX_SEGMENTS: usize = 4;
|
||||
const MAX_SEGMENT_BYTES: usize = 128;
|
||||
const MAX_WALK_SAMPLES: usize = 32;
|
||||
const MAX_WALK_BYTES: usize = 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ProposalError {
|
||||
EntryLimit,
|
||||
ByteLimit,
|
||||
InvalidKey,
|
||||
fn segment_producers() -> BTreeSet<SegmentInvalidationProducer> {
|
||||
SegmentInvalidationProducer::REQUIRED.into_iter().collect()
|
||||
}
|
||||
|
||||
// Keys come from successful fixture writes, not a production mutation stream.
|
||||
fn fixture_proposal(keys: &[&str]) -> Result<BTreeSet<String>, ProposalError> {
|
||||
let mut segments = BTreeSet::new();
|
||||
let mut bytes = 0;
|
||||
for key in keys {
|
||||
if key.is_empty() || key.contains(['\\', '\0']) || key.split('/').any(|part| matches!(part, "" | "." | "..")) {
|
||||
return Err(ProposalError::InvalidKey);
|
||||
}
|
||||
let segment = key.split('/').next().expect("validated nonempty key");
|
||||
if segments.contains(segment) {
|
||||
continue;
|
||||
}
|
||||
if segments.len() == MAX_SEGMENTS {
|
||||
return Err(ProposalError::EntryLimit);
|
||||
}
|
||||
if segment.len() > MAX_SEGMENT_BYTES - bytes {
|
||||
return Err(ProposalError::ByteLimit);
|
||||
}
|
||||
bytes += segment.len();
|
||||
segments.insert(segment.to_string());
|
||||
fn segment_envelope() -> SegmentInvalidationEnvelope {
|
||||
SegmentInvalidationEnvelope {
|
||||
source: DataUsageCacheSource::new(2, 3),
|
||||
bucket_incarnation: uuid::Uuid::from_u128(0x12345678123456781234567812345678),
|
||||
key_format: crate::DATA_USAGE_CACHE_KEY_FORMAT,
|
||||
baseline_scan_plan_digest: DataUsageScanPlanDigest([9; 32]),
|
||||
process_epoch: "epoch-a".to_string(),
|
||||
generation_start: 11,
|
||||
generation_end: 13,
|
||||
restart_gap: false,
|
||||
overflow: false,
|
||||
producers: segment_producers(),
|
||||
}
|
||||
}
|
||||
|
||||
fn segment_proof() -> SegmentInvalidationProof {
|
||||
let envelope = segment_envelope();
|
||||
SegmentInvalidationProof {
|
||||
source: envelope.source,
|
||||
bucket_incarnation: envelope.bucket_incarnation,
|
||||
key_format: envelope.key_format,
|
||||
baseline_scan_plan_digest: envelope.baseline_scan_plan_digest,
|
||||
process_epoch: envelope.process_epoch,
|
||||
generation_start: envelope.generation_start,
|
||||
generation_end: envelope.generation_end,
|
||||
durable_producer_identity: true,
|
||||
invalidation_domain: SegmentInvalidationDomain::LocalSingleSet,
|
||||
distributed_ec_invalidation: false,
|
||||
cold_zero_walk_oracle: true,
|
||||
}
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_observation_fixture_proposal_bounds() {
|
||||
assert_eq!(fixture_proposal(&["hot/one", "hot/two"]), Ok(BTreeSet::from(["hot".to_string()])));
|
||||
assert_eq!(fixture_proposal(&["a", "b", "c", "d"]).expect("entry boundary").len(), MAX_SEGMENTS);
|
||||
assert_eq!(fixture_proposal(&["a", "b", "c", "d", "e"]), Err(ProposalError::EntryLimit));
|
||||
let exact = "x".repeat(MAX_SEGMENT_BYTES);
|
||||
assert!(fixture_proposal(&[&exact]).is_ok());
|
||||
assert_eq!(fixture_proposal(&[&exact, "y"]), Err(ProposalError::ByteLimit));
|
||||
let oversized = "x".repeat(MAX_SEGMENT_BYTES + 1);
|
||||
assert_eq!(fixture_proposal(&[&oversized]), Err(ProposalError::ByteLimit));
|
||||
let envelope = segment_envelope();
|
||||
let proof = segment_proof();
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, ["hot/one", "hot/two"]),
|
||||
Ok(BTreeSet::from(["hot".to_string()]))
|
||||
);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, ["a", "b", "c", "d"])
|
||||
.expect("entry boundary")
|
||||
.len(),
|
||||
MAX_SEGMENT_INVALIDATION_ENTRIES
|
||||
);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, ["a", "b", "c", "d", "e"]),
|
||||
Err(SegmentInvalidationError::EntryLimit)
|
||||
);
|
||||
let exact = "x".repeat(MAX_SEGMENT_INVALIDATION_BYTES);
|
||||
assert!(admit_segment_invalidation(&envelope, &proof, [&exact]).is_ok());
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, [&exact, "y"]),
|
||||
Err(SegmentInvalidationError::ByteLimit)
|
||||
);
|
||||
let oversized = "x".repeat(MAX_SEGMENT_INVALIDATION_BYTES + 1);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, [&oversized]),
|
||||
Err(SegmentInvalidationError::ByteLimit)
|
||||
);
|
||||
for key in ["", "/hot", "hot/../cold", "hot//one", "hot\\one", "hot/\0"] {
|
||||
assert_eq!(fixture_proposal(&[key]), Err(ProposalError::InvalidKey));
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, [key]),
|
||||
Err(SegmentInvalidationError::InvalidKey)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_observation_trusted_proposal_requires_identity_and_complete_producer_coverage() {
|
||||
let envelope = segment_envelope();
|
||||
let proof = segment_proof();
|
||||
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, ["hot/one", "hot/two", "archive/delete-marker"]),
|
||||
Ok(BTreeSet::from(["archive".to_string(), "hot".to_string()]))
|
||||
);
|
||||
|
||||
let mut wrong_source = envelope.clone();
|
||||
wrong_source.source = DataUsageCacheSource::new(2, 4);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&wrong_source, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut missing_incarnation = envelope.clone();
|
||||
missing_incarnation.bucket_incarnation = uuid::Uuid::nil();
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&missing_incarnation, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_key_format = envelope.clone();
|
||||
wrong_key_format.key_format = crate::DATA_USAGE_CACHE_KEY_FORMAT.saturating_add(1);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&wrong_key_format, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_baseline = envelope.clone();
|
||||
wrong_baseline.baseline_scan_plan_digest = DataUsageScanPlanDigest([8; 32]);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&wrong_baseline, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_epoch = envelope.clone();
|
||||
wrong_epoch.process_epoch = "epoch-b".to_string();
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&wrong_epoch, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_generation_start = proof.clone();
|
||||
wrong_generation_start.generation_start = wrong_generation_start.generation_start.saturating_sub(1);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &wrong_generation_start, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_generation_end = proof.clone();
|
||||
wrong_generation_end.generation_end = wrong_generation_end.generation_end.saturating_add(1);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &wrong_generation_end, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut no_durable_identity = proof.clone();
|
||||
no_durable_identity.durable_producer_identity = false;
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &no_durable_identity, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut restart_gap = envelope.clone();
|
||||
restart_gap.restart_gap = true;
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&restart_gap, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut overflow = envelope.clone();
|
||||
overflow.overflow = true;
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&overflow, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut generation_gap = envelope.clone();
|
||||
generation_gap.generation_end = generation_gap.generation_start - 1;
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&generation_gap, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut missing_producer = envelope.clone();
|
||||
missing_producer.producers.remove(&SegmentInvalidationProducer::Replication);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&missing_producer, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut missing_zero_walk_oracle = proof.clone();
|
||||
missing_zero_walk_oracle.cold_zero_walk_oracle = false;
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &missing_zero_walk_oracle, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut distributed_without_invalidation = proof;
|
||||
distributed_without_invalidation.invalidation_domain = SegmentInvalidationDomain::DistributedEc;
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &distributed_without_invalidation, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut distributed_with_invalidation = distributed_without_invalidation;
|
||||
distributed_with_invalidation.distributed_ec_invalidation = true;
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &distributed_with_invalidation, ["hot/one", "hot/two", "archive/delete-marker"]),
|
||||
Ok(BTreeSet::from(["archive".to_string(), "hot".to_string()]))
|
||||
);
|
||||
}
|
||||
|
||||
fn cache_value(cache: &DataUsageCache) -> serde_json::Value {
|
||||
let mut value = serde_json::to_value(cache).expect("serialize the entire cache");
|
||||
// Children are a HashSet: canonicalize only that unordered field, without
|
||||
@@ -116,7 +261,8 @@ async fn walk_and_save(observe: bool) -> (Vec<String>, serde_json::Value) {
|
||||
assert!(path.len() <= MAX_WALK_BYTES - bytes, "fixture walk exceeded its byte budget");
|
||||
paths.push(path.to_string());
|
||||
if observe {
|
||||
let proposed = fixture_proposal(&[changed_key]).expect("bounded successful fixture mutation");
|
||||
let proposed = admit_segment_invalidation(&segment_envelope(), &segment_proof(), [changed_key])
|
||||
.expect("bounded successful fixture mutation");
|
||||
if let Some(segment) = path.strip_prefix("bucket/").and_then(|path| path.split('/').next())
|
||||
&& proposed.contains(segment)
|
||||
{
|
||||
@@ -181,10 +327,6 @@ async fn walk_and_save(observe: bool) -> (Vec<String>, serde_json::Value) {
|
||||
2,
|
||||
"the two non-proposed segments must still be walked"
|
||||
);
|
||||
eprintln!(
|
||||
"segment fixture: proposed={proposed:?}, actual_segments={walked_segments:?}, actual_walk_callbacks={}, production_producer_coverage=unverified",
|
||||
paths.len()
|
||||
);
|
||||
} else {
|
||||
assert!(proposed_walked.lock().expect("read disabled observations").is_empty());
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ pub(in crate::scanner_folder) fn observe_raw_entry(dir: &str, name: &std::ffi::O
|
||||
let relative_dir = Path::new(dir)
|
||||
.strip_prefix(&observation.root)
|
||||
.unwrap_or_else(|_| Path::new(""));
|
||||
if relative_dir.components().count() != 1 {
|
||||
return;
|
||||
}
|
||||
let entry_marker = relative_dir.join(name).to_string_lossy().to_string();
|
||||
observation.first_entry.get_or_insert_with(|| entry_marker.clone());
|
||||
observation.last_entry = Some(entry_marker);
|
||||
@@ -130,7 +133,13 @@ async fn round(request: &Request) -> serde_json::Value {
|
||||
.await
|
||||
.expect("open synthetic disk in this process");
|
||||
let parent = CancellationToken::new();
|
||||
let budget = ScannerCycleBudget::new_with_progress_tracking(&parent, Default::default());
|
||||
let budget = ScannerCycleBudget::new_with_progress_tracking(
|
||||
&parent,
|
||||
crate::scanner_budget::ScannerCycleBudgetConfig {
|
||||
max_objects: Some(request.raw_entry_budget),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let _observation_guard = install_raw_entry_budget(disk.path(), request.raw_entry_budget);
|
||||
let result = scan_data_folder(
|
||||
budget.token(),
|
||||
@@ -157,6 +166,22 @@ async fn round(request: &Request) -> serde_json::Value {
|
||||
let reloaded = DataUsageCache::unmarshal(&read_bounded(&cache_path).await).expect("reload returned cache codec");
|
||||
let retained = reloaded.checked_flatten("bucket").expect("reloaded bucket root");
|
||||
let scanned = returned.checked_flatten("bucket").expect("returned bucket root");
|
||||
let raw_page_index = reloaded.validated_raw_enumeration_page_index();
|
||||
let raw_page_index_committed_entries = raw_page_index
|
||||
.and_then(|index| index.committed_entries().ok())
|
||||
.map(|entries| entries.len())
|
||||
.unwrap_or(0);
|
||||
let raw_page_index_indexed_entries = raw_page_index
|
||||
.and_then(|index| index.indexed_entries().ok())
|
||||
.map(|entries| entries.len())
|
||||
.unwrap_or(0);
|
||||
let (raw_page_index_parent, raw_page_index_complete) = raw_page_index
|
||||
.map(|index| match index.status() {
|
||||
crate::raw_page_index::RawEnumerationPageOwnerStatus::Building { parent, .. } => (Some(parent), false),
|
||||
crate::raw_page_index::RawEnumerationPageOwnerStatus::Ready { parent, complete, .. } => (Some(parent), complete),
|
||||
crate::raw_page_index::RawEnumerationPageOwnerStatus::Unsupported => (None, false),
|
||||
})
|
||||
.unwrap_or((None, false));
|
||||
assert_eq!(
|
||||
(retained.objects, retained.versions, retained.size),
|
||||
(scanned.objects, scanned.versions, scanned.size)
|
||||
@@ -169,6 +194,10 @@ async fn round(request: &Request) -> serde_json::Value {
|
||||
"objects_expected": request.objects, "raw_entry_budget": request.raw_entry_budget,
|
||||
"raw_entries": observation.entries, "raw_name_bytes": observation.name_bytes,
|
||||
"raw_first_entry": observation.first_entry, "raw_last_entry": observation.last_entry,
|
||||
"raw_page_index_parent": raw_page_index_parent,
|
||||
"raw_page_index_complete": raw_page_index_complete,
|
||||
"raw_page_index_committed_entries": raw_page_index_committed_entries,
|
||||
"raw_page_index_indexed_entries": raw_page_index_indexed_entries,
|
||||
"objects_processed": budget.progress().0,
|
||||
"objects_before": before, "objects_retained": retained.objects,
|
||||
"versions_retained": retained.versions, "bytes_retained": retained.size,
|
||||
@@ -194,16 +223,16 @@ async fn enumeration_restart_worker() {
|
||||
let temp = tempfile::tempdir().expect("healthy fixture directory");
|
||||
let report = round(&Request {
|
||||
workspace: temp.path().to_path_buf(),
|
||||
objects: 4,
|
||||
objects: 8,
|
||||
raw_entry_budget: 16,
|
||||
round: 0,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(report["outcome"], "complete");
|
||||
assert_eq!(report["snapshot_complete"], true);
|
||||
assert_eq!(report["objects_retained"], 4);
|
||||
assert_eq!(report["versions_retained"], 4);
|
||||
assert_eq!(report["bytes_retained"], 4);
|
||||
assert_eq!(report["objects_retained"], 8);
|
||||
assert_eq!(report["versions_retained"], 8);
|
||||
assert_eq!(report["bytes_retained"], 8);
|
||||
assert!(report["raw_entries"].as_u64().expect("observed entries") >= 8, "{report}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,6 +741,11 @@ pub(crate) fn cache_root_entry_info(cache: &DataUsageCache) -> std::result::Resu
|
||||
name: cache.info.name.clone(),
|
||||
parent: DATA_USAGE_ROOT.to_string(),
|
||||
entry,
|
||||
bucket_incarnation: cache
|
||||
.info
|
||||
.scan_identity
|
||||
.map(|identity| identity.bucket_incarnation)
|
||||
.filter(|incarnation| !incarnation.is_nil()),
|
||||
tier_registry_generation: cache.info.tier_registry_generation,
|
||||
})
|
||||
}
|
||||
@@ -752,6 +757,14 @@ fn apply_bucket_result_to_cache(cache: &mut DataUsageCache, result: DataUsageEnt
|
||||
// forces the caller to re-account it under one frozen registry.
|
||||
return false;
|
||||
}
|
||||
match result.bucket_incarnation {
|
||||
Some(incarnation) if !incarnation.is_nil() => {
|
||||
cache.info.scan_bucket_incarnations.insert(result.name.clone(), incarnation);
|
||||
}
|
||||
_ => {
|
||||
cache.info.scan_bucket_incarnations.remove(&result.name);
|
||||
}
|
||||
}
|
||||
cache.replace(&result.name, &result.parent, result.entry);
|
||||
cache.info.last_update = Some(update_time);
|
||||
true
|
||||
|
||||
@@ -898,6 +898,54 @@ mod tests {
|
||||
assert!(cohort.overflowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn service_cohort_overflow_new_lower_names_do_not_starve_cursor_tail() {
|
||||
let source = DataUsageCacheSource::new(0, 0);
|
||||
let mut cohort = ScannerServiceCohort {
|
||||
max_members: 2,
|
||||
max_name_bytes: 64,
|
||||
..Default::default()
|
||||
};
|
||||
let first = cohort_inventory(&["mm00", "zz10", "zz20", "zz30"]);
|
||||
cohort.refresh(&first);
|
||||
cohort.record_admitted(source, "mm00");
|
||||
cohort.record_admitted(source, "zz10");
|
||||
|
||||
let with_new_lower_names = cohort_inventory(&["aa-new", "ab-new", "mm00", "zz10", "zz20", "zz30"]);
|
||||
cohort.refresh(&with_new_lower_names);
|
||||
assert_eq!(
|
||||
cohort.members[&source].keys().map(AsRef::as_ref).collect::<HashSet<&str>>(),
|
||||
HashSet::from(["zz20", "zz30"]),
|
||||
"cursor-tail members must enter the tracked window before newly injected lower names"
|
||||
);
|
||||
let mut ordered = with_new_lower_names[&source].clone();
|
||||
cohort.order_buckets(source, &mut ordered);
|
||||
assert_eq!(
|
||||
ordered.iter().take(2).map(|bucket| bucket.name.as_str()).collect::<Vec<_>>(),
|
||||
vec!["zz20", "zz30"],
|
||||
"dispatch order must keep the old overflow tail ahead of newer lower names"
|
||||
);
|
||||
|
||||
cohort.record_admitted(source, "zz20");
|
||||
let with_more_lower_names = cohort_inventory(&["a0-new", "aa-new", "ab-new", "mm00", "zz10", "zz20", "zz30"]);
|
||||
cohort.refresh(&with_more_lower_names);
|
||||
let mut ordered = with_more_lower_names[&source].clone();
|
||||
cohort.order_buckets(source, &mut ordered);
|
||||
assert_eq!(
|
||||
ordered.first().map(|bucket| bucket.name.as_str()),
|
||||
Some("zz30"),
|
||||
"a still-waiting cursor-tail member must retain priority across repeated lower-name arrivals"
|
||||
);
|
||||
|
||||
cohort.record_admitted(source, "zz30");
|
||||
cohort.refresh(&with_more_lower_names);
|
||||
assert!(
|
||||
cohort.members[&source].keys().any(|bucket| bucket.as_ref().starts_with('a')),
|
||||
"new lower names become eligible after the cursor tail has been serviced"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn service_cohort_bounds_names_and_does_not_reset_duplicate_dirty_age() {
|
||||
|
||||
@@ -34,17 +34,12 @@ pub(super) fn prepare_scoped_set_scan(
|
||||
all_buckets: &[BucketInfo],
|
||||
scope: &ScannerBucketScanScope,
|
||||
generation: ScannerSetCacheGeneration,
|
||||
current_bucket_incarnations: Option<&HashMap<String, uuid::Uuid>>,
|
||||
) -> Option<PreparedScopedSetScan> {
|
||||
let (Some(selected_buckets), Some(baseline_scan_plan_digest)) = (&scope.selected_buckets, scope.baseline_scan_plan_digest)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
// The existing cache does not bind each bucket to a durable incarnation.
|
||||
// Listing creation times can come from volume metadata, so even Some(time)
|
||||
// cannot prove that an unselected same-name bucket is the cached bucket.
|
||||
if all_buckets.iter().any(|bucket| !selected_buckets.contains(&bucket.name)) {
|
||||
return None;
|
||||
}
|
||||
if selected_buckets.is_empty()
|
||||
|| !old_cache.info.snapshot_complete
|
||||
|| old_cache.info.last_update.is_none()
|
||||
@@ -56,6 +51,7 @@ pub(super) fn prepare_scoped_set_scan(
|
||||
|| old_cache.info.scan_plan_digest != Some(baseline_scan_plan_digest)
|
||||
|| old_cache.info.cache_key_format != DATA_USAGE_CACHE_KEY_FORMAT
|
||||
|| !old_cache.has_complete_root_inventory(&old_cache.find(DATA_USAGE_ROOT)?.children)
|
||||
|| !unselected_bucket_incarnations_match(old_cache, all_buckets, selected_buckets, current_bucket_incarnations)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -75,6 +71,7 @@ pub(super) fn prepare_scoped_set_scan(
|
||||
lkg_last_update: old_cache.info.last_update,
|
||||
lkg_leader_epoch: Some(old_cache.info.leader_epoch),
|
||||
lkg_scan_plan_digest: old_cache.info.scan_plan_digest,
|
||||
scan_bucket_incarnations: old_cache.info.scan_bucket_incarnations.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
cache: HashMap::new(),
|
||||
@@ -85,7 +82,15 @@ pub(super) fn prepare_scoped_set_scan(
|
||||
if !current_bucket_names.insert(bucket.name.as_str()) {
|
||||
return None;
|
||||
}
|
||||
cache.replace(&bucket.name, DATA_USAGE_ROOT, DataUsageEntry::default());
|
||||
if selected_buckets.contains(&bucket.name) {
|
||||
cache.replace(&bucket.name, DATA_USAGE_ROOT, DataUsageEntry::default());
|
||||
} else {
|
||||
cache.copy_with_children(
|
||||
old_cache,
|
||||
&rustfs_data_usage::hash_path(&bucket.name),
|
||||
&Some(rustfs_data_usage::hash_path(DATA_USAGE_ROOT)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some(PreparedScopedSetScan {
|
||||
@@ -98,6 +103,47 @@ pub(super) fn prepare_scoped_set_scan(
|
||||
})
|
||||
}
|
||||
|
||||
fn unselected_bucket_incarnations_match(
|
||||
old_cache: &DataUsageCache,
|
||||
all_buckets: &[BucketInfo],
|
||||
selected_buckets: &HashSet<String>,
|
||||
current_bucket_incarnations: Option<&HashMap<String, uuid::Uuid>>,
|
||||
) -> bool {
|
||||
let Some(current_bucket_incarnations) = current_bucket_incarnations else {
|
||||
return all_buckets.iter().all(|bucket| selected_buckets.contains(&bucket.name));
|
||||
};
|
||||
all_buckets
|
||||
.iter()
|
||||
.filter(|bucket| !selected_buckets.contains(&bucket.name))
|
||||
.all(|bucket| {
|
||||
let Some(current) = current_bucket_incarnations
|
||||
.get(&bucket.name)
|
||||
.filter(|incarnation| !incarnation.is_nil())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
old_cache
|
||||
.info
|
||||
.scan_bucket_incarnations
|
||||
.get(&bucket.name)
|
||||
.filter(|cached| !cached.is_nil())
|
||||
== Some(current)
|
||||
})
|
||||
}
|
||||
|
||||
async fn scanner_current_bucket_incarnations(set: &SetDisks, all_buckets: &[BucketInfo]) -> Option<HashMap<String, uuid::Uuid>> {
|
||||
let mut incarnations = HashMap::with_capacity(all_buckets.len());
|
||||
for bucket in all_buckets {
|
||||
let Ok(incarnation) = set.bucket_incarnation_id_from_disk(&bucket.name).await else {
|
||||
return None;
|
||||
};
|
||||
if incarnation.is_nil() || incarnations.insert(bucket.name.clone(), incarnation).is_some() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(incarnations)
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ScannerIOCache for SetDisks {
|
||||
#[tracing::instrument(skip(self, budget, scan_plan, updates))]
|
||||
@@ -158,6 +204,7 @@ impl ScannerIOCache for SetDisks {
|
||||
None
|
||||
}
|
||||
};
|
||||
let current_bucket_incarnations = scanner_current_bucket_incarnations(self.as_ref(), &all_buckets).await;
|
||||
let scoped_scan = prepare_scoped_set_scan(
|
||||
&old_cache,
|
||||
&buckets,
|
||||
@@ -170,6 +217,7 @@ impl ScannerIOCache for SetDisks {
|
||||
source,
|
||||
scan_plan_digest,
|
||||
},
|
||||
current_bucket_incarnations.as_ref(),
|
||||
);
|
||||
let mut scoped_cache = scoped_scan.map(|mut prepared| {
|
||||
buckets = prepared.buckets;
|
||||
@@ -191,6 +239,7 @@ impl ScannerIOCache for SetDisks {
|
||||
scan_plan_digest: Some(scan_plan_digest),
|
||||
scan_coverage_digest: Some(bucket_coverage_digest),
|
||||
cache_key_format: DATA_USAGE_CACHE_KEY_FORMAT,
|
||||
scan_bucket_incarnations: current_bucket_incarnations.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
cache: HashMap::new(),
|
||||
@@ -486,6 +535,7 @@ impl ScannerIOCache for SetDisks {
|
||||
lkg_last_update: old_cache.info.lkg_last_update,
|
||||
lkg_leader_epoch: old_cache.info.lkg_leader_epoch,
|
||||
lkg_scan_plan_digest: old_cache.info.lkg_scan_plan_digest,
|
||||
scan_bucket_incarnations: current_bucket_incarnations.clone().unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
cache: HashMap::new(),
|
||||
@@ -574,7 +624,6 @@ impl ScannerIOCache for SetDisks {
|
||||
let budget_clone = budget.clone();
|
||||
let store_clone_clone = self.clone();
|
||||
let bucket_result_tx_clone = bucket_result_tx.clone();
|
||||
let disk_clone = disk.clone();
|
||||
let set_disk_inventory_clone = set_disk_inventory.clone();
|
||||
let disk_scan_semaphore_clone = disk_scan_semaphore.clone();
|
||||
let queued_disk_bucket_scans_clone = queued_disk_bucket_scans.clone();
|
||||
@@ -622,10 +671,7 @@ impl ScannerIOCache for SetDisks {
|
||||
BucketWorkGuard::new(remaining_bucket_work_clone.clone(), bucket_work_complete_clone.clone());
|
||||
// Prefix hints are process-local. Never hand one to a
|
||||
// remote or legacy-coordinator disk path.
|
||||
let prefix_scan_scope = disk_clone
|
||||
.is_local()
|
||||
.then(|| scope_clone.prefix_scope_for(&bucket.name))
|
||||
.flatten();
|
||||
let prefix_scan_scope = disk.is_local().then(|| scope_clone.prefix_scope_for(&bucket.name)).flatten();
|
||||
|
||||
metrics::histogram!(
|
||||
METRIC_SCANNER_DISK_SCAN_WAIT_SECONDS,
|
||||
@@ -680,7 +726,7 @@ impl ScannerIOCache for SetDisks {
|
||||
};
|
||||
remote_session_sequence = next_sequence;
|
||||
let remote_outcome = crate::remote_scanner::scan_remote_bucket(
|
||||
&disk_clone,
|
||||
&disk,
|
||||
ctx_clone.clone(),
|
||||
budget_clone.clone(),
|
||||
crate::remote_scanner::RemoteScannerScanSpec {
|
||||
@@ -813,8 +859,8 @@ impl ScannerIOCache for SetDisks {
|
||||
continue;
|
||||
}
|
||||
|
||||
let _local_admission = if disk_clone.is_local() {
|
||||
match crate::remote_scanner::try_admit_remote_scanner(&disk_clone) {
|
||||
let _local_admission = if disk.is_local() {
|
||||
match crate::remote_scanner::try_admit_remote_scanner(&disk) {
|
||||
Ok(admission) => Some(admission),
|
||||
Err(e) => {
|
||||
if requeue_bucket_work(&bucket_tx_clone, &bucket, &mut work_guard).await {
|
||||
@@ -1055,7 +1101,7 @@ impl ScannerIOCache for SetDisks {
|
||||
let before = cache.info.last_update;
|
||||
|
||||
let scan_ctx = ctx_clone.child_token();
|
||||
let scan = disk_clone.clone().nsscanner_disk(
|
||||
let scan = disk.clone().nsscanner_disk(
|
||||
scan_ctx.clone(),
|
||||
budget_clone.clone(),
|
||||
set_disk_inventory_clone.as_ref().clone(),
|
||||
|
||||
@@ -365,14 +365,16 @@ async fn scoped_scan_production_entry_preserves_deep_and_full_maintenance_work()
|
||||
.enumerate()
|
||||
{
|
||||
if index > 0 {
|
||||
let mutated_bucket = if index == 1 { "hot-bucket" } else { "cold-bucket" };
|
||||
let mut reader = ScannerPutObjReader::from_vec(b"maintenance".to_vec());
|
||||
store.pools[0].disk_set[0]
|
||||
.put_object("cold-bucket", &format!("added-{index}"), &mut reader, &ScannerObjectOptions::default())
|
||||
.put_object(mutated_bucket, &format!("added-{index}"), &mut reader, &ScannerObjectOptions::default())
|
||||
.await
|
||||
.expect("cold bucket mutation should persist");
|
||||
.expect("maintenance object should persist");
|
||||
wait_for_namespace_commit_tails(store.as_ref()).await;
|
||||
// Only the hot bucket is in the usage hint. The cold result must
|
||||
// come from this cycle's storage walk, not its previous baseline.
|
||||
// Only the hot bucket is in the dirty-usage hint. The ordinary
|
||||
// dirty cycle exercises scoped reuse; the following maintenance
|
||||
// cycles mutate cold storage and must still walk it.
|
||||
record_dirty_usage_bucket("hot-bucket");
|
||||
}
|
||||
let requested_scope = if explicit_scope {
|
||||
@@ -425,11 +427,14 @@ async fn scoped_scan_production_entry_preserves_deep_and_full_maintenance_work()
|
||||
}
|
||||
let mut snapshot = receiver.recv().await.expect("cycle should publish a snapshot");
|
||||
assert!(snapshot.usage_snapshot_complete, "cycle {cycle}");
|
||||
assert_eq!(
|
||||
snapshot.buckets_usage["cold-bucket"].objects_count,
|
||||
u64::try_from(index + 1).expect("count should fit")
|
||||
);
|
||||
assert_eq!(snapshot.buckets_usage["hot-bucket"].objects_count, 1);
|
||||
let expected_hot_count = if index >= 1 { 2 } else { 1 };
|
||||
let expected_cold_count = if index >= 2 {
|
||||
u64::try_from(index).expect("count should fit")
|
||||
} else {
|
||||
1
|
||||
};
|
||||
assert_eq!(snapshot.buckets_usage["cold-bucket"].objects_count, expected_cold_count);
|
||||
assert_eq!(snapshot.buckets_usage["hot-bucket"].objects_count, expected_hot_count);
|
||||
assert_eq!(snapshot.scanner_cycle, Some(cycle));
|
||||
assert_eq!(snapshot.scanner_epoch, Some(11));
|
||||
snapshot.usage_snapshot_converged = Some(true);
|
||||
@@ -1232,6 +1237,19 @@ fn complete_set_usage_cache(buckets: &[(&str, usize)], scan_plan_digest: DataUsa
|
||||
cache
|
||||
}
|
||||
|
||||
fn test_bucket_incarnations(buckets: &[&str]) -> HashMap<String, Uuid> {
|
||||
buckets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, bucket)| {
|
||||
(
|
||||
(*bucket).to_string(),
|
||||
Uuid::from_u128(u128::try_from(index).expect("test index should fit") + 1),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn set_snapshot_reuse_requires_execution_identity_and_fences_stale_writers() {
|
||||
@@ -1651,6 +1669,82 @@ fn scoped_scan_uses_only_locally_verified_prefix_hints() {
|
||||
assert!(distributed_scope.prefix_scope_for("photos").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_dirty_usage_invalidates_local_prefix_hints_until_distributed_proof_exists() {
|
||||
let source = DataUsageCacheSource::new(1, 2);
|
||||
let expected_sources = HashSet::from([source]);
|
||||
let scan_plan_digest = DataUsageScanPlanDigest([6; 32]);
|
||||
let baseline = complete_usage_baseline(source, scan_plan_digest, 7, 11);
|
||||
let expected_peers = HashMap::from([(
|
||||
"node-a:9000".to_string(),
|
||||
ScannerPeerDirtyUsageExpectation {
|
||||
instance_id: "instance-a".to_string(),
|
||||
generation: 7,
|
||||
pending: true,
|
||||
},
|
||||
)]);
|
||||
let remote_dirty_usage = verified_remote_dirty_usage(
|
||||
&expected_peers,
|
||||
vec![(
|
||||
"node-a:9000".to_string(),
|
||||
peer_dirty_usage_snapshot("instance-a", 7, true, &[("photos", 7)]),
|
||||
)],
|
||||
)
|
||||
.expect("fixture remote dirty usage should verify at bucket granularity");
|
||||
let dirty_scopes = HashMap::from([(
|
||||
"photos".to_string(),
|
||||
DirtyUsageBucketScope::TopLevelEntries(HashSet::from(["2026".to_string()])),
|
||||
)]);
|
||||
let locally_scoped = scoped_scan_scope_from_dirty_buckets(
|
||||
ScannerBucketScanScope::default(),
|
||||
HashSet::from(["photos".to_string()]),
|
||||
Some(&dirty_scopes),
|
||||
true,
|
||||
&[bucket_info("photos")],
|
||||
ScannerCacheBaselineProof {
|
||||
authoritative_data: Some(&baseline),
|
||||
observed_candidate_data: None,
|
||||
expected_sources: &expected_sources,
|
||||
leader_epoch: 11,
|
||||
want_cycle: 8,
|
||||
scan_plan_digest,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
locally_scoped.prefix_scope_for("photos").is_some(),
|
||||
"local-only evidence may narrow to a direct child segment"
|
||||
);
|
||||
|
||||
let distributed = resolve_remote_dirty_usage_scope(
|
||||
ScannerBucketScanScope::default(),
|
||||
HashSet::from(["photos".to_string()]),
|
||||
remote_dirty_usage,
|
||||
&[bucket_info("photos")],
|
||||
ScannerCacheBaselineProof {
|
||||
authoritative_data: Some(&baseline),
|
||||
observed_candidate_data: None,
|
||||
expected_sources: &expected_sources,
|
||||
leader_epoch: 11,
|
||||
want_cycle: 8,
|
||||
scan_plan_digest,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
distributed
|
||||
.scope
|
||||
.selected_buckets
|
||||
.as_deref()
|
||||
.expect("distributed invalidation still selects the dirty bucket"),
|
||||
&HashSet::from(["photos".to_string()])
|
||||
);
|
||||
assert!(
|
||||
distributed.scope.prefix_scope_for("photos").is_none(),
|
||||
"peer dirty state is not a distributed segment invalidation proof"
|
||||
);
|
||||
assert_eq!(distributed.remote_dirty_usage_acknowledgements.len(), 1);
|
||||
}
|
||||
|
||||
fn peer_dirty_usage_snapshot(
|
||||
instance_id: &str,
|
||||
generation: u64,
|
||||
@@ -2006,6 +2100,7 @@ fn scoped_set_scan_rebuilds_selected_buckets_and_drops_deleted_buckets() {
|
||||
source: DataUsageCacheSource::new(1, 2),
|
||||
scan_plan_digest: current_digest,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.expect("complete matching set cache should support a scoped scan");
|
||||
|
||||
@@ -2029,6 +2124,57 @@ fn scoped_set_scan_rebuilds_selected_buckets_and_drops_deleted_buckets() {
|
||||
assert_eq!(prepared.cache.info.lkg_scan_plan_digest, Some(baseline_digest));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_set_scan_reuses_unselected_buckets_with_matching_incarnations() {
|
||||
let baseline_digest = DataUsageScanPlanDigest([1; 32]);
|
||||
let current_digest = DataUsageScanPlanDigest([2; 32]);
|
||||
let mut old_cache = complete_set_usage_cache(&[("stable", 10), ("dirty", 20)], baseline_digest);
|
||||
old_cache.replace(
|
||||
"stable/prefix",
|
||||
"stable",
|
||||
DataUsageEntry {
|
||||
size: 5,
|
||||
objects: 1,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
old_cache.info.scan_bucket_incarnations = test_bucket_incarnations(&["stable", "dirty"]);
|
||||
let current_incarnations = old_cache.info.scan_bucket_incarnations.clone();
|
||||
let all_buckets = vec![
|
||||
bucket_info_with_created_time("stable"),
|
||||
bucket_info_with_created_time("dirty"),
|
||||
];
|
||||
|
||||
let prepared = prepare_scoped_set_scan(
|
||||
&old_cache,
|
||||
&all_buckets,
|
||||
&all_buckets,
|
||||
&ScannerBucketScanScope {
|
||||
selected_buckets: Some(Arc::new(HashSet::from(["dirty".to_string()]))),
|
||||
selected_bucket_prefixes: None,
|
||||
baseline_scan_plan_digest: Some(baseline_digest),
|
||||
},
|
||||
ScannerSetCacheGeneration {
|
||||
want_cycle: 8,
|
||||
leader_epoch: 11,
|
||||
tier_registry_generation: 13,
|
||||
source: DataUsageCacheSource::new(1, 2),
|
||||
scan_plan_digest: current_digest,
|
||||
},
|
||||
Some(¤t_incarnations),
|
||||
)
|
||||
.expect("matching bucket incarnations should authorize cold bucket reuse");
|
||||
|
||||
assert_eq!(prepared.buckets.iter().map(|bucket| bucket.name.as_str()).collect::<Vec<_>>(), ["dirty"]);
|
||||
let stable = prepared
|
||||
.cache
|
||||
.checked_flatten("stable")
|
||||
.expect("unselected stable bucket should be copied with children");
|
||||
assert_eq!((stable.size, stable.objects), (15, 2));
|
||||
assert_eq!(prepared.cache.find("dirty").map(|entry| (entry.size, entry.objects)), Some((0, 0)));
|
||||
assert_eq!(prepared.cache.info.scan_bucket_incarnations, current_incarnations);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_set_scan_rejects_unbound_bucket_incarnations() {
|
||||
let baseline_digest = DataUsageScanPlanDigest([1; 32]);
|
||||
@@ -2054,10 +2200,22 @@ fn scoped_set_scan_rejects_unbound_bucket_incarnations() {
|
||||
stable.created = created;
|
||||
let buckets = vec![stable, bucket_info_with_created_time("dirty")];
|
||||
assert!(
|
||||
prepare_scoped_set_scan(&old_cache, &buckets, &buckets, &scope, generation).is_none(),
|
||||
prepare_scoped_set_scan(&old_cache, &buckets, &buckets, &scope, generation, None).is_none(),
|
||||
"missing identity, volume timestamps and same-name recreation must all rebuild"
|
||||
);
|
||||
}
|
||||
let mut mismatched = test_bucket_incarnations(&["stable", "dirty"]);
|
||||
mismatched.insert("stable".to_string(), Uuid::from_u128(99));
|
||||
let mut old_cache = old_cache;
|
||||
old_cache.info.scan_bucket_incarnations = test_bucket_incarnations(&["stable", "dirty"]);
|
||||
let buckets = vec![
|
||||
bucket_info_with_created_time("stable"),
|
||||
bucket_info_with_created_time("dirty"),
|
||||
];
|
||||
assert!(
|
||||
prepare_scoped_set_scan(&old_cache, &buckets, &buckets, &scope, generation, Some(&mismatched)).is_none(),
|
||||
"a same-name unselected bucket with a different incarnation must rebuild"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2083,6 +2241,7 @@ fn scoped_set_scan_falls_back_when_an_unselected_bucket_has_no_baseline() {
|
||||
source: DataUsageCacheSource::new(1, 2),
|
||||
scan_plan_digest: DataUsageScanPlanDigest([4; 32]),
|
||||
},
|
||||
Some(&test_bucket_incarnations(&["stable", "new"])),
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
@@ -2107,19 +2266,19 @@ fn scoped_set_scan_requires_an_exact_complete_baseline() {
|
||||
|
||||
let mut incomplete = complete_set_usage_cache(&[("dirty", 10)], baseline_digest);
|
||||
incomplete.info.snapshot_complete = false;
|
||||
assert!(prepare_scoped_set_scan(&incomplete, &all_buckets, &all_buckets, &scope, generation).is_none());
|
||||
assert!(prepare_scoped_set_scan(&incomplete, &all_buckets, &all_buckets, &scope, generation, None).is_none());
|
||||
|
||||
let mut not_durable = complete_set_usage_cache(&[("dirty", 10)], baseline_digest);
|
||||
not_durable.info.last_update = None;
|
||||
assert!(prepare_scoped_set_scan(¬_durable, &all_buckets, &all_buckets, &scope, generation).is_none());
|
||||
assert!(prepare_scoped_set_scan(¬_durable, &all_buckets, &all_buckets, &scope, generation, None).is_none());
|
||||
|
||||
let mut unscoped_usage = complete_set_usage_cache(&[("dirty", 10)], baseline_digest);
|
||||
unscoped_usage.cache.get_mut(DATA_USAGE_ROOT).expect("set root").objects = 1;
|
||||
assert!(prepare_scoped_set_scan(&unscoped_usage, &all_buckets, &all_buckets, &scope, generation).is_none());
|
||||
assert!(prepare_scoped_set_scan(&unscoped_usage, &all_buckets, &all_buckets, &scope, generation, None).is_none());
|
||||
|
||||
let mut wrong_digest = complete_set_usage_cache(&[("dirty", 10)], baseline_digest);
|
||||
wrong_digest.info.scan_plan_digest = Some(DataUsageScanPlanDigest([7; 32]));
|
||||
assert!(prepare_scoped_set_scan(&wrong_digest, &all_buckets, &all_buckets, &scope, generation).is_none());
|
||||
assert!(prepare_scoped_set_scan(&wrong_digest, &all_buckets, &all_buckets, &scope, generation, None).is_none());
|
||||
|
||||
let empty_scope = ScannerBucketScanScope {
|
||||
selected_buckets: Some(Arc::new(HashSet::new())),
|
||||
@@ -2127,18 +2286,18 @@ fn scoped_set_scan_requires_an_exact_complete_baseline() {
|
||||
baseline_scan_plan_digest: Some(baseline_digest),
|
||||
};
|
||||
let complete = complete_set_usage_cache(&[("dirty", 10)], baseline_digest);
|
||||
assert!(prepare_scoped_set_scan(&complete, &all_buckets, &all_buckets, &empty_scope, generation).is_none());
|
||||
assert!(prepare_scoped_set_scan(&complete, &all_buckets, &all_buckets, &scope, generation).is_some());
|
||||
assert!(prepare_scoped_set_scan(&complete, &all_buckets, &all_buckets, &empty_scope, generation, None).is_none());
|
||||
assert!(prepare_scoped_set_scan(&complete, &all_buckets, &all_buckets, &scope, generation, None).is_some());
|
||||
|
||||
let unidentified_buckets = vec![bucket_info("dirty")];
|
||||
assert!(
|
||||
prepare_scoped_set_scan(&complete, &unidentified_buckets, &unidentified_buckets, &scope, generation).is_some(),
|
||||
prepare_scoped_set_scan(&complete, &unidentified_buckets, &unidentified_buckets, &scope, generation, None).is_some(),
|
||||
"fully selected buckets are rebuilt without reusing an unproven incarnation"
|
||||
);
|
||||
|
||||
let mut future_cache = complete_set_usage_cache(&[("dirty", 10)], baseline_digest);
|
||||
future_cache.info.next_cycle = generation.want_cycle.saturating_add(1);
|
||||
assert!(prepare_scoped_set_scan(&future_cache, &all_buckets, &all_buckets, &scope, generation).is_none());
|
||||
assert!(prepare_scoped_set_scan(&future_cache, &all_buckets, &all_buckets, &scope, generation, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3007,6 +3166,7 @@ fn apply_bucket_result_to_cache_updates_bucket_entry() {
|
||||
objects: 2,
|
||||
..Default::default()
|
||||
},
|
||||
bucket_incarnation: Some(Uuid::from_u128(7)),
|
||||
tier_registry_generation: None,
|
||||
},
|
||||
update_time,
|
||||
@@ -3016,6 +3176,7 @@ fn apply_bucket_result_to_cache_updates_bucket_entry() {
|
||||
let entry = cache.find("bucket").expect("bucket entry should remain present");
|
||||
assert_eq!(entry.size, 10);
|
||||
assert_eq!(entry.objects, 2);
|
||||
assert_eq!(cache.info.scan_bucket_incarnations.get("bucket"), Some(&Uuid::from_u128(7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3046,6 +3207,7 @@ fn apply_bucket_result_to_cache_rejects_a_different_tier_generation() {
|
||||
size: 11,
|
||||
..Default::default()
|
||||
},
|
||||
bucket_incarnation: Some(Uuid::from_u128(7)),
|
||||
tier_registry_generation: Some(8),
|
||||
},
|
||||
SystemTime::now(),
|
||||
|
||||
@@ -82,8 +82,8 @@ async fn persist_baseline(store: &Arc<ECStore>, baseline: &DataUsageInfo) {
|
||||
.expect("fixture baseline should persist");
|
||||
}
|
||||
|
||||
// Every invocation uses the production default scope. The expected walker set
|
||||
// comes from storage's per-source inventory, not the resolver's selected names.
|
||||
// Every invocation uses the production default scope. Once durable bucket
|
||||
// incarnations are present, the expected walker set follows the resolved scope.
|
||||
async fn run_entry(store: &Arc<ECStore>, cycle: u64, selected: Option<&str>, expect_walks: bool) -> DataUsageInfo {
|
||||
let drives = drive_identities(store).await;
|
||||
let inventory = store
|
||||
@@ -99,6 +99,7 @@ async fn run_entry(store: &Arc<ECStore>, cycle: u64, selected: Option<&str>, exp
|
||||
let source = DataUsageCacheSource::new(set.pool_index, set.set_index);
|
||||
set.buckets.into_iter().map(move |bucket| ((source, bucket.name), 1_u64))
|
||||
})
|
||||
.filter(|((_, bucket), _)| selected.is_none_or(|selected| bucket == selected))
|
||||
.collect::<HashMap<_, _>>()
|
||||
} else {
|
||||
HashMap::new()
|
||||
@@ -201,8 +202,8 @@ async fn scoped_entry_fallback_distinguishes_planned_scope_from_real_cold_walks(
|
||||
let baseline = run_entry(&store, 1, None, true).await;
|
||||
persist_baseline(&store, &baseline).await;
|
||||
|
||||
// A same-intent, same-cycle Current cache is a retry, not proof that a
|
||||
// later cycle may reuse unselected buckets without durable incarnation.
|
||||
// Same-cycle Current remains a retry. The later cycle may skip the cold
|
||||
// bucket only after the prior complete set cache has durable incarnations.
|
||||
run_entry(&store, 1, Some(&hot), false).await;
|
||||
let usage = run_entry(&store, 2, Some(&hot), true).await;
|
||||
assert_eq!(usage.buckets_usage[&hot].objects_count, 1);
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
// 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 crate::{DATA_USAGE_CACHE_KEY_FORMAT, DataUsageCacheSource, DataUsageScanPlanDigest};
|
||||
use std::collections::BTreeSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const MAX_SEGMENT_INVALIDATION_ENTRIES: usize = 4;
|
||||
pub const MAX_SEGMENT_INVALIDATION_BYTES: usize = 128;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SegmentInvalidationError {
|
||||
EntryLimit,
|
||||
ByteLimit,
|
||||
InvalidProof,
|
||||
InvalidKey,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum SegmentInvalidationProducer {
|
||||
Put,
|
||||
Delete,
|
||||
DeleteMarker,
|
||||
Multipart,
|
||||
Replication,
|
||||
Tier,
|
||||
DirectoryObject,
|
||||
}
|
||||
|
||||
impl SegmentInvalidationProducer {
|
||||
pub const REQUIRED: [Self; 7] = [
|
||||
Self::Put,
|
||||
Self::Delete,
|
||||
Self::DeleteMarker,
|
||||
Self::Multipart,
|
||||
Self::Replication,
|
||||
Self::Tier,
|
||||
Self::DirectoryObject,
|
||||
];
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SegmentInvalidationDomain {
|
||||
LocalSingleSet,
|
||||
DistributedEc,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SegmentInvalidationEnvelope {
|
||||
pub source: DataUsageCacheSource,
|
||||
pub bucket_incarnation: Uuid,
|
||||
pub key_format: u16,
|
||||
pub baseline_scan_plan_digest: DataUsageScanPlanDigest,
|
||||
pub process_epoch: String,
|
||||
pub generation_start: u64,
|
||||
pub generation_end: u64,
|
||||
pub restart_gap: bool,
|
||||
pub overflow: bool,
|
||||
pub producers: BTreeSet<SegmentInvalidationProducer>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SegmentInvalidationProof {
|
||||
pub source: DataUsageCacheSource,
|
||||
pub bucket_incarnation: Uuid,
|
||||
pub key_format: u16,
|
||||
pub baseline_scan_plan_digest: DataUsageScanPlanDigest,
|
||||
pub process_epoch: String,
|
||||
pub generation_start: u64,
|
||||
pub generation_end: u64,
|
||||
pub durable_producer_identity: bool,
|
||||
pub invalidation_domain: SegmentInvalidationDomain,
|
||||
pub distributed_ec_invalidation: bool,
|
||||
pub cold_zero_walk_oracle: bool,
|
||||
}
|
||||
|
||||
pub fn admit_segment_invalidation<I, K>(
|
||||
envelope: &SegmentInvalidationEnvelope,
|
||||
proof: &SegmentInvalidationProof,
|
||||
keys: I,
|
||||
) -> Result<BTreeSet<String>, SegmentInvalidationError>
|
||||
where
|
||||
I: IntoIterator<Item = K>,
|
||||
K: AsRef<str>,
|
||||
{
|
||||
validate_segment_invalidation_proof(envelope, proof)?;
|
||||
segment_invalidation_top_level_entries(keys)
|
||||
}
|
||||
|
||||
fn validate_segment_invalidation_proof(
|
||||
envelope: &SegmentInvalidationEnvelope,
|
||||
proof: &SegmentInvalidationProof,
|
||||
) -> Result<(), SegmentInvalidationError> {
|
||||
if envelope.source != proof.source
|
||||
|| envelope.bucket_incarnation.is_nil()
|
||||
|| envelope.bucket_incarnation != proof.bucket_incarnation
|
||||
|| envelope.key_format != DATA_USAGE_CACHE_KEY_FORMAT
|
||||
|| envelope.key_format != proof.key_format
|
||||
|| envelope.baseline_scan_plan_digest != proof.baseline_scan_plan_digest
|
||||
|| envelope.process_epoch.is_empty()
|
||||
|| envelope.process_epoch != proof.process_epoch
|
||||
|| envelope.generation_start != proof.generation_start
|
||||
|| envelope.generation_end != proof.generation_end
|
||||
|| !proof.durable_producer_identity
|
||||
|| !proof.cold_zero_walk_oracle
|
||||
|| (proof.invalidation_domain == SegmentInvalidationDomain::DistributedEc && !proof.distributed_ec_invalidation)
|
||||
|| envelope.generation_start == 0
|
||||
|| envelope.generation_end < envelope.generation_start
|
||||
|| proof.generation_start == 0
|
||||
|| proof.generation_end < proof.generation_start
|
||||
|| envelope.restart_gap
|
||||
|| envelope.overflow
|
||||
|| !SegmentInvalidationProducer::REQUIRED
|
||||
.iter()
|
||||
.all(|producer| envelope.producers.contains(producer))
|
||||
{
|
||||
return Err(SegmentInvalidationError::InvalidProof);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn segment_invalidation_top_level_entries<I, K>(keys: I) -> Result<BTreeSet<String>, SegmentInvalidationError>
|
||||
where
|
||||
I: IntoIterator<Item = K>,
|
||||
K: AsRef<str>,
|
||||
{
|
||||
let mut segments = BTreeSet::new();
|
||||
let mut bytes = 0usize;
|
||||
for key in keys {
|
||||
let segment = top_level_segment(key.as_ref())?;
|
||||
if segments.contains(segment) {
|
||||
continue;
|
||||
}
|
||||
if segments.len() == MAX_SEGMENT_INVALIDATION_ENTRIES {
|
||||
return Err(SegmentInvalidationError::EntryLimit);
|
||||
}
|
||||
if segment.len() > MAX_SEGMENT_INVALIDATION_BYTES - bytes {
|
||||
return Err(SegmentInvalidationError::ByteLimit);
|
||||
}
|
||||
bytes += segment.len();
|
||||
segments.insert(segment.to_string());
|
||||
}
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
fn top_level_segment(key: &str) -> Result<&str, SegmentInvalidationError> {
|
||||
if key.is_empty()
|
||||
|| key.starts_with('/')
|
||||
|| key.contains(['\\', '\0'])
|
||||
|| key.split('/').any(|part| matches!(part, "" | "." | ".."))
|
||||
{
|
||||
return Err(SegmentInvalidationError::InvalidKey);
|
||||
}
|
||||
Ok(key.split('/').next().expect("validated nonempty key"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn producers() -> BTreeSet<SegmentInvalidationProducer> {
|
||||
SegmentInvalidationProducer::REQUIRED.into_iter().collect()
|
||||
}
|
||||
|
||||
fn envelope() -> SegmentInvalidationEnvelope {
|
||||
SegmentInvalidationEnvelope {
|
||||
source: DataUsageCacheSource::new(2, 3),
|
||||
bucket_incarnation: Uuid::from_u128(0x12345678123456781234567812345678),
|
||||
key_format: DATA_USAGE_CACHE_KEY_FORMAT,
|
||||
baseline_scan_plan_digest: DataUsageScanPlanDigest([9; 32]),
|
||||
process_epoch: "epoch-a".to_string(),
|
||||
generation_start: 11,
|
||||
generation_end: 13,
|
||||
restart_gap: false,
|
||||
overflow: false,
|
||||
producers: producers(),
|
||||
}
|
||||
}
|
||||
|
||||
fn proof() -> SegmentInvalidationProof {
|
||||
let envelope = envelope();
|
||||
SegmentInvalidationProof {
|
||||
source: envelope.source,
|
||||
bucket_incarnation: envelope.bucket_incarnation,
|
||||
key_format: envelope.key_format,
|
||||
baseline_scan_plan_digest: envelope.baseline_scan_plan_digest,
|
||||
process_epoch: envelope.process_epoch,
|
||||
generation_start: envelope.generation_start,
|
||||
generation_end: envelope.generation_end,
|
||||
durable_producer_identity: true,
|
||||
invalidation_domain: SegmentInvalidationDomain::LocalSingleSet,
|
||||
distributed_ec_invalidation: false,
|
||||
cold_zero_walk_oracle: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_invalidation_admits_only_complete_identity_proof() {
|
||||
let envelope = envelope();
|
||||
let proof = proof();
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, ["hot/one", "hot/two", "archive/delete-marker"]),
|
||||
Ok(BTreeSet::from(["archive".to_string(), "hot".to_string()]))
|
||||
);
|
||||
|
||||
let mut wrong_source = envelope.clone();
|
||||
wrong_source.source = DataUsageCacheSource::new(2, 4);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&wrong_source, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut missing_incarnation = envelope.clone();
|
||||
missing_incarnation.bucket_incarnation = Uuid::nil();
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&missing_incarnation, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_key_format = envelope.clone();
|
||||
wrong_key_format.key_format = DATA_USAGE_CACHE_KEY_FORMAT.saturating_add(1);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&wrong_key_format, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_baseline = envelope.clone();
|
||||
wrong_baseline.baseline_scan_plan_digest = DataUsageScanPlanDigest([8; 32]);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&wrong_baseline, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_epoch = envelope.clone();
|
||||
wrong_epoch.process_epoch = "epoch-b".to_string();
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&wrong_epoch, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_generation_start = proof.clone();
|
||||
wrong_generation_start.generation_start = wrong_generation_start.generation_start.saturating_sub(1);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &wrong_generation_start, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut wrong_generation_end = proof.clone();
|
||||
wrong_generation_end.generation_end = wrong_generation_end.generation_end.saturating_add(1);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &wrong_generation_end, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut no_durable_identity = proof.clone();
|
||||
no_durable_identity.durable_producer_identity = false;
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &no_durable_identity, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut restart_gap = envelope.clone();
|
||||
restart_gap.restart_gap = true;
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&restart_gap, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut overflow = envelope.clone();
|
||||
overflow.overflow = true;
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&overflow, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut generation_gap = envelope.clone();
|
||||
generation_gap.generation_end = generation_gap.generation_start - 1;
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&generation_gap, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut missing_producer = envelope.clone();
|
||||
missing_producer.producers.remove(&SegmentInvalidationProducer::Replication);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&missing_producer, &proof, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut missing_zero_walk_oracle = proof.clone();
|
||||
missing_zero_walk_oracle.cold_zero_walk_oracle = false;
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &missing_zero_walk_oracle, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut distributed_without_invalidation = proof;
|
||||
distributed_without_invalidation.invalidation_domain = SegmentInvalidationDomain::DistributedEc;
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &distributed_without_invalidation, ["hot/one"]),
|
||||
Err(SegmentInvalidationError::InvalidProof)
|
||||
);
|
||||
|
||||
let mut distributed_with_invalidation = distributed_without_invalidation;
|
||||
distributed_with_invalidation.distributed_ec_invalidation = true;
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &distributed_with_invalidation, ["hot/one"]),
|
||||
Ok(BTreeSet::from(["hot".to_string()]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_invalidation_entries_are_bounded_and_key_checked() {
|
||||
let envelope = envelope();
|
||||
let proof = proof();
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, ["hot/one", "hot/two"]),
|
||||
Ok(BTreeSet::from(["hot".to_string()]))
|
||||
);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, ["a", "b", "c", "d"])
|
||||
.expect("entry boundary")
|
||||
.len(),
|
||||
MAX_SEGMENT_INVALIDATION_ENTRIES
|
||||
);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, ["a", "b", "c", "d", "e"]),
|
||||
Err(SegmentInvalidationError::EntryLimit)
|
||||
);
|
||||
let exact = "x".repeat(MAX_SEGMENT_INVALIDATION_BYTES);
|
||||
assert!(admit_segment_invalidation(&envelope, &proof, [&exact]).is_ok());
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, [&exact, "y"]),
|
||||
Err(SegmentInvalidationError::ByteLimit)
|
||||
);
|
||||
let oversized = "x".repeat(MAX_SEGMENT_INVALIDATION_BYTES + 1);
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, [&oversized]),
|
||||
Err(SegmentInvalidationError::ByteLimit)
|
||||
);
|
||||
for key in ["", "/hot", "hot/../cold", "hot//one", "hot\\one", "hot/\0"] {
|
||||
assert_eq!(
|
||||
admit_segment_invalidation(&envelope, &proof, [key]),
|
||||
Err(SegmentInvalidationError::InvalidKey)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2552,7 +2552,7 @@ mod serial_tests {
|
||||
.expect("Failed to upload multipart part");
|
||||
completed.push(CompletePart {
|
||||
part_num: idx + 1,
|
||||
etag: part.etag.clone(),
|
||||
etag: part.etag,
|
||||
..Default::default()
|
||||
});
|
||||
offset += part_size;
|
||||
|
||||
@@ -125,6 +125,7 @@ const EXTERNAL_COMPATIBLE_SUFFIXES: &[&str] = &[
|
||||
"ACCESS_KEY",
|
||||
"ACCESS_KEY_FILE",
|
||||
"ADDRESS",
|
||||
"API_OBJECT_MAX_VERSIONS",
|
||||
"API_XFF_HEADER",
|
||||
"AUDIT_WEBHOOK_AUTH_TOKEN",
|
||||
"AUDIT_WEBHOOK_CLIENT_CERT",
|
||||
@@ -900,4 +901,15 @@ mod tests {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_env_compat_includes_api_object_max_versions() {
|
||||
let report =
|
||||
build_external_env_compat_report_from_entries([("MINIO_API_OBJECT_MAX_VERSIONS".to_string(), "50000".to_string())]);
|
||||
|
||||
assert_eq!(
|
||||
report.mapped_pairs,
|
||||
vec![("MINIO_API_OBJECT_MAX_VERSIONS".to_string(), "RUSTFS_API_OBJECT_MAX_VERSIONS".to_string())]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,16 @@ pub const SUFFIX_FORCE_DELETE: &str = "force-delete";
|
||||
pub const SUFFIX_INCLUDE_DELETED: &str = "include-deleted";
|
||||
pub const SUFFIX_REPLICATION_RESET_STATUS: &str = "replication-reset-status";
|
||||
pub const SUFFIX_REPLICATION_ACTUAL_OBJECT_SIZE: &str = "replication-actual-object-size";
|
||||
/// SSE-C ciphertext passthrough of an object the source stored compressed:
|
||||
/// the stored compression scheme travels under this name so the replica
|
||||
/// decompresses after decrypting (backlog#2363).
|
||||
pub const SUFFIX_REPLICATION_COMPRESSION: &str = "replication-compression";
|
||||
/// Plaintext size of a compressed passthrough object (backlog#2363).
|
||||
pub const SUFFIX_REPLICATION_COMPRESSION_ACTUAL_SIZE: &str = "replication-compression-actual-size";
|
||||
/// Plaintext length of one passthrough multipart part, sent on UploadPart so
|
||||
/// the replica records the logical part size and checks the 5 MiB minimum
|
||||
/// against it rather than against the stored bytes (backlog#2363).
|
||||
pub const SUFFIX_REPLICATION_PART_ACTUAL_SIZE: &str = "replication-part-actual-size";
|
||||
pub const SUFFIX_SOURCE_VERSION_ID: &str = "source-version-id";
|
||||
pub const SUFFIX_SOURCE_MTIME: &str = "source-mtime";
|
||||
pub const SUFFIX_SOURCE_ETAG: &str = "source-etag";
|
||||
|
||||
@@ -93,6 +93,10 @@ pub const SUFFIX_TIER_SKIP_FV_ID: &str = "tier-skip-fvid";
|
||||
|
||||
/// Per-target delete-marker version ids are stored one key per target ARN.
|
||||
pub const SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX: &str = "replication-delete-marker-version-";
|
||||
/// Per-target data-version ids, one key per target ARN: the version a
|
||||
/// replication target that mints its own ids assigned to this object version
|
||||
/// (rustfs/backlog#2340). Absent on targets that adopt the source id.
|
||||
pub const SUFFIX_REPLICATION_TARGET_VERSION_ARN_PREFIX: &str = "replication-target-version-";
|
||||
|
||||
// On-demand migration provenance. Written by the migration write-back onto
|
||||
// every pulled object so operators and later tooling can tell a migrated
|
||||
@@ -314,6 +318,17 @@ pub fn strip_internal_prefix_preserving_case(key: &str) -> Option<&str> {
|
||||
/// Reads the bounded per-target delete-marker version map in one metadata scan.
|
||||
/// The boolean is set when matching metadata is malformed or compatibility keys disagree.
|
||||
pub fn target_delete_marker_versions(map: &HashMap<String, String>) -> (HashMap<String, String>, bool) {
|
||||
internal_versions_by_arn(map, SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX)
|
||||
}
|
||||
|
||||
/// Reads the bounded per-target data-version ledger (the id each drifting
|
||||
/// target assigned to this object version) in one metadata scan. Same
|
||||
/// bounds and corruption reporting as [`target_delete_marker_versions`].
|
||||
pub fn replication_target_versions(map: &HashMap<String, String>) -> (HashMap<String, String>, bool) {
|
||||
internal_versions_by_arn(map, SUFFIX_REPLICATION_TARGET_VERSION_ARN_PREFIX)
|
||||
}
|
||||
|
||||
fn internal_versions_by_arn(map: &HashMap<String, String>, arn_prefix: &str) -> (HashMap<String, String>, bool) {
|
||||
const MAX_ENTRIES: usize = 1_000;
|
||||
const MAX_ARN_LEN: usize = 1_024;
|
||||
const MAX_VERSION_ID_LEN: usize = 1_024;
|
||||
@@ -324,13 +339,13 @@ pub fn target_delete_marker_versions(map: &HashMap<String, String>) -> (HashMap<
|
||||
let Some(suffix) = strip_internal_prefix_preserving_case(key) else {
|
||||
continue;
|
||||
};
|
||||
let Some(prefix) = suffix.get(..SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX.len()) else {
|
||||
let Some(prefix) = suffix.get(..arn_prefix.len()) else {
|
||||
continue;
|
||||
};
|
||||
if !prefix.eq_ignore_ascii_case(SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX) {
|
||||
if !prefix.eq_ignore_ascii_case(arn_prefix) {
|
||||
continue;
|
||||
}
|
||||
let arn = &suffix[SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX.len()..];
|
||||
let arn = &suffix[arn_prefix.len()..];
|
||||
if !arn.starts_with("arn:") || arn.len() > MAX_ARN_LEN || value.is_empty() || value.len() > MAX_VERSION_ID_LEN {
|
||||
corrupt = true;
|
||||
continue;
|
||||
@@ -703,6 +718,37 @@ mod tests {
|
||||
assert!(corrupt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replication_target_versions_are_keyed_apart_from_delete_marker_versions() {
|
||||
let arn = "arn:rustfs:replication::target";
|
||||
let mut metadata = HashMap::new();
|
||||
insert_str(
|
||||
&mut metadata,
|
||||
&format!("{SUFFIX_REPLICATION_TARGET_VERSION_ARN_PREFIX}{arn}"),
|
||||
"data-version".to_string(),
|
||||
);
|
||||
insert_str(
|
||||
&mut metadata,
|
||||
&format!("{SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX}{arn}"),
|
||||
"marker-version".to_string(),
|
||||
);
|
||||
|
||||
let (data_versions, corrupt) = replication_target_versions(&metadata);
|
||||
assert!(!corrupt);
|
||||
assert_eq!(data_versions.get(arn).map(String::as_str), Some("data-version"));
|
||||
let (marker_versions, corrupt) = target_delete_marker_versions(&metadata);
|
||||
assert!(!corrupt);
|
||||
assert_eq!(marker_versions.get(arn).map(String::as_str), Some("marker-version"));
|
||||
|
||||
metadata.insert(
|
||||
format!("{MINIO_INTERNAL_PREFIX}{SUFFIX_REPLICATION_TARGET_VERSION_ARN_PREFIX}{arn}"),
|
||||
"other-version".to_string(),
|
||||
);
|
||||
let (data_versions, corrupt) = replication_target_versions(&metadata);
|
||||
assert!(data_versions.is_empty());
|
||||
assert!(corrupt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_delete_marker_versions_bound_distinct_entries_during_scan() {
|
||||
let metadata = (0..=1_000)
|
||||
|
||||
@@ -13,6 +13,47 @@
|
||||
// limitations under the License.
|
||||
|
||||
use std::fmt;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Bounds a repetitive diagnostic without changing its underlying counters.
|
||||
/// Each emitted event includes the number suppressed since the previous one.
|
||||
pub struct LogThrottle {
|
||||
interval_ms: u64,
|
||||
last_ms: AtomicU64,
|
||||
suppressed: AtomicU64,
|
||||
}
|
||||
|
||||
impl LogThrottle {
|
||||
pub const fn new(interval_ms: u64) -> Self {
|
||||
Self {
|
||||
interval_ms,
|
||||
last_ms: AtomicU64::new(u64::MAX),
|
||||
suppressed: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn claim(&self) -> Option<u64> {
|
||||
static ANCHOR: OnceLock<std::time::Instant> = OnceLock::new();
|
||||
let now = ANCHOR.get_or_init(std::time::Instant::now).elapsed().as_millis();
|
||||
self.claim_at(u64::try_from(now).unwrap_or(u64::MAX - 1))
|
||||
}
|
||||
|
||||
fn claim_at(&self, now: u64) -> Option<u64> {
|
||||
let last = self.last_ms.load(Ordering::Relaxed);
|
||||
if (last == u64::MAX || now.saturating_sub(last) >= self.interval_ms)
|
||||
&& self
|
||||
.last_ms
|
||||
.compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
Some(self.suppressed.swap(0, Ordering::Relaxed))
|
||||
} else {
|
||||
self.suppressed.fetch_add(1, Ordering::Relaxed);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct MaskedAccessKey<'a>(pub &'a str);
|
||||
@@ -51,7 +92,32 @@ impl fmt::Debug for MaskedAccessKey<'_> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MaskedAccessKey;
|
||||
use super::{LogThrottle, MaskedAccessKey};
|
||||
|
||||
#[test]
|
||||
fn log_throttle_emits_once_per_interval_and_reports_suppression() {
|
||||
let throttle = LogThrottle::new(5_000);
|
||||
assert_eq!(throttle.claim_at(0), Some(0));
|
||||
assert_eq!(throttle.claim_at(1), None);
|
||||
assert_eq!(throttle.claim_at(4_999), None);
|
||||
assert_eq!(throttle.claim_at(5_000), Some(2));
|
||||
assert_eq!(throttle.claim_at(5_001), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_throttle_allows_only_one_concurrent_claim() {
|
||||
let throttle = LogThrottle::new(5_000);
|
||||
let reported = std::thread::scope(|scope| {
|
||||
let threads: Vec<_> = (0..16).map(|_| scope.spawn(|| throttle.claim_at(0))).collect();
|
||||
let emitted: Vec<_> = threads
|
||||
.into_iter()
|
||||
.filter_map(|thread| thread.join().expect("claim worker"))
|
||||
.collect();
|
||||
assert_eq!(emitted.len(), 1);
|
||||
emitted[0]
|
||||
});
|
||||
assert_eq!(reported + throttle.claim_at(5_000).expect("next window"), 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_short_values() {
|
||||
|
||||
@@ -61,3 +61,5 @@ Required headings and strings in these files are asserted by `scripts/check_arch
|
||||
| [minio-file-format-compat.md](minio-file-format-compat.md) | deciding whether a MinIO drive set, bucket-metadata blob, or SSE object can be read or imported by a given RustFS build, or before touching a listed version anchor |
|
||||
|
||||
Operations runbooks live in [../operations/](../README.md#operations) and testing references in [../testing/README.md](../testing/README.md).
|
||||
|
||||
For per-node HTTP failure ratios and cached storage probe provenance, see [S3 write failure diagnostics](../operations/s3-write-failure-diagnostics.md).
|
||||
|
||||
@@ -40,6 +40,9 @@ an unknown or unsupported peer-health snapshot degrades readiness with
|
||||
- Liveness reports process availability and must not depend on storage, IAM,
|
||||
lock quorum, or peer health.
|
||||
- Node readiness reports local dependency readiness.
|
||||
- A blocked pool metadata writer degrades node and cluster-write readiness with
|
||||
`pool_metadata_blocked`. Metadata save-gate inspection is bounded to 100 ms;
|
||||
contention reports `pool_metadata_check_timeout` without installing a block.
|
||||
- Cluster write readiness requires write quorum and the runtime dependency
|
||||
readiness used by `FullReady`.
|
||||
- Cluster read readiness may use the read-quorum path and cluster-health
|
||||
|
||||
@@ -32,6 +32,27 @@ A V3 update first conditionally writes a pending generation containing the last
|
||||
|
||||
Do not hand-edit a pending record or select a replica only because it is in pool zero. Preserve all copies when escalating recovery.
|
||||
|
||||
## Runtime write recovery
|
||||
|
||||
A runtime metadata or identity read failure before any write is dispatched rejects that operation but does not permanently block subsequent retries. Errors retain their typed cause; pool metadata unavailability reaches S3 as `503 ServiceUnavailable`, without exposing internal error details. Cancellation during preflight or a rejected first conditional write is also retryable. After any identity, prepare, or commit write may have started, cancellation, an uncertain write result, or abandoned runtime publication blocks further metadata-dependent mutations.
|
||||
|
||||
The node checks for interrupted pool metadata transactions every five seconds. Failed recovery attempts back off to at most sixty seconds; each attempt has a thirty-second budget and stops on shutdown. Healthy nodes do not read metadata for this worker. Recovery:
|
||||
|
||||
1. Cancels old decommission workers and waits for their supervisors to drain them. It does not cancel a separate rebalance operation; an attached rebalance worker prevents recovery until quiescent.
|
||||
2. Holds the local start/movement gates and distributed `pool.bin` write fence, validates the initialized deployment identity and unchanged pool topology, and selects the authoritative durable transaction.
|
||||
3. Repairs pending, missing, or lagging copies using conditional writes, then rereads and verifies convergence. A prepare-only first V3 migration commits the predecessor as V3, preserving the observed format floor.
|
||||
4. Invalidates old movement snapshots, installs the verified durable state, rechecks the fence, and only then clears the block. Speculative in-memory progress is never used as the recovery source. The existing decommission supervisor resumes eligible work afterward.
|
||||
|
||||
An unreadable replica, lost fence, or conditional-write conflict leaves the original block in place. Recovery never initializes an all-missing metadata set. Corruption, incompatible layouts, conflicting identities/epochs/transactions, and topology changes require operator reconciliation; restore readability and consistency using the procedures below. Blocks originating in startup validation or storage-format heal are not cleared by the pool transaction worker: restart only after repairing the underlying condition. There is no force-clear switch. If an attached rebalance worker cannot quiesce, collect its status and restart the affected node after verifying the durable metadata; do not manually detach its worker token.
|
||||
|
||||
### Diagnostics
|
||||
|
||||
- The first block emits `decommission_state` with `state=pool_metadata_blocked`, `reason`, `phase`, and `blocked_since`. A change in recovery failure classification emits `state=pool_metadata_recovery_pending`; successful recovery emits `state=pool_metadata_recovered` with the original timestamp.
|
||||
- `rustfs_pool_metadata_blocks_total{reason}` and `rustfs_pool_metadata_recoveries_total` count block and recovery transitions. The original cause and phase remain attached to local typed errors; storage/RPC error numbers and on-disk formats are unchanged.
|
||||
- Node and cluster-write readiness include `pool_metadata_blocked`. Waiting for the metadata save mutex is bounded to 100 ms and reports `pool_metadata_check_timeout`, not a persistent block. Cluster probes retain their existing cache and overall timeout behavior. Liveness and cluster-read quorum checks are unchanged.
|
||||
|
||||
If a block persists, inspect the first block and subsequent recovery phase, restore disk/peer readability, and verify every metadata and identity copy before restarting. Do not delete metadata to make readiness green.
|
||||
|
||||
## Disk replacement and metadata erasure
|
||||
|
||||
1. Keep a quorum of nodes online and verify the cluster is ready.
|
||||
@@ -39,4 +60,4 @@ Do not hand-edit a pending record or select a replica only because it is in pool
|
||||
3. Restore storage formats and the `pool.bin.identity` marker from the same deployment before rejoining it.
|
||||
4. Start the node and wait for it to load the verified committed generation and repair its replicas before touching another node.
|
||||
|
||||
An initialized identity with every `pool.bin` missing is recovery required, as are existing storage formats with neither identity nor `pool.bin`. Format creation alone is not fresh-cluster proof: only the elected first topology node may create a durable `initialized=false` bootstrap identity with a fresh-bootstrap nonce, and only after every configured disk explicitly responds that it is unformatted. An unreachable peer, a non-elected distributed node, or an existing format is not sufficient proof. All-missing `pool.bin` replicas are accepted only by the same startup that proved the fresh topology and persisted that pending identity; when every `pool.bin` is missing, a later startup must recover even if the pending identity survived. This prevents a wiped or lagging node from rebuilding empty state and overwriting the cluster. Runtime reload, rebalance activation, and rebalance worker admission all fail closed and latch the same recovery gate until the node is restarted with readable metadata.
|
||||
An initialized identity with every `pool.bin` missing is recovery required, as are existing storage formats with neither identity nor `pool.bin`. Format creation alone is not fresh-cluster proof: only the elected first topology node may create a durable `initialized=false` bootstrap identity with a fresh-bootstrap nonce, and only after every configured disk explicitly responds that it is unformatted. An unreachable peer, a non-elected distributed node, or an existing format is not sufficient proof. All-missing `pool.bin` replicas are accepted only by the same startup that proved the fresh topology and persisted that pending identity; when every `pool.bin` is missing, a later startup must recover even if the pending identity survived. This prevents a wiped or lagging node from rebuilding empty state and overwriting the cluster. Runtime reload, rebalance activation, and rebalance worker admission fail closed on this missing-authority condition; a clean probe alone cannot clear it.
|
||||
|
||||
@@ -62,4 +62,4 @@ The route returns HTTP 200 with JSON after all configured targets have been chec
|
||||
|
||||
`VersionFidelity` pins the version-identity contract on both write paths. The probe PUT carries a source version id (header plus `?versionId=` query, the exact shape live replication uses) and the target must answer with the same id; a second probe repeats the check through CreateMultipartUpload -> UploadPart -> CompleteMultipartUpload, where the target fixes the version at initiate and only reports it on completion. A target can adopt PutObject ids and still mint its own for multipart; the failure message names the path that drifted.
|
||||
|
||||
A target that mints its own version ids breaks every version-addressed operation that follows (version deletes, heal re-drives). The phase therefore fails with `"Code": "BucketRemoteTargetVersionMismatch"`, the later mutation phases are skipped, and cleanup still removes the probe via the version id the target actually assigned.
|
||||
A target that mints its own version ids never answers to the source version id. The phase therefore fails with `"Code": "BucketRemoteTargetVersionMismatch"` and the target result is `FAILED`. Replication to such a target still converges: the replication worker records the id the target assigned to each object version on the source (the target-version ledger, internal metadata key `replication-target-version-<arn>`) and addresses version deletes, tag and Object Lock updates through it. The `DeleteMarker` and `VersionDelete` phases probe exactly that path — they address the id the target assigned to the probe object, not the source id — so on a drifting target they report whether ledger-addressed purges work against this endpoint, and cleanup removes the probe via the same id. They are `SKIPPED` only when the probe `Put` itself failed or reported no version id.
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
| Rejects or mis-stores `aws-chunked` bodies (SeaweedFS 3.97) | Handled by the plain-payload default above. | Outbound target matrix, `RejectAwsChunked` mode |
|
||||
| Requires `Content-MD5` or `x-amz-checksum-*` on a PutObject with Object Lock parameters (AWS S3, MinIO, Impossible Cloud, most compatible stores) | Satisfied: a locked single PUT carries `Content-MD5` derived from the source ETag (plaintext objects whose ETag is the MD5 of the wire bytes) or an SDK CRC32 checksum (multipart-layout ETags, managed SSE, SSE-C passthrough — this one is an `aws-chunked` trailer, so a target that also rejects that framing cannot take such objects). Releases before this fix (`1.0.0-rc.5`) need `RUSTFS_REPLICATION_STREAMING_CHECKSUMS=true` as a workaround. | Outbound target matrix, `RequireChecksumWithObjectLock` mode |
|
||||
| Stores `x-amz-checksum-*` from a PutObject and returns it on `HEAD ?ChecksumMode=ENABLED` (AWS S3, Wasabi, RustFS) | Satisfied for single-part objects: the replica answers with the source's checksum. Before this fix (`1.0.0-rc.5`) the checksum left the source as `x-amz-meta-<algorithm>` user metadata and no replica carried it (rustfs/backlog#2340). | Outbound target matrix, `Checksummed` shape |
|
||||
| Mints its own version ids (AWS S3, Wasabi, Impossible Cloud) | Data lands; version-addressed convergence does not. See rustfs/backlog#2085 and `docs/operations/replication-check.md` (VersionFidelity). | `replication-check`, outbound target matrix, `MintOwnVersionIds` mode |
|
||||
| Mints its own version ids (AWS S3, Wasabi, Impossible Cloud) | Data lands, and later mutations converge through the target-version ledger: the id the target assigned is recorded on the source version (internal key `replication-target-version-<arn>`) and version deletes, tag and Object Lock updates address it (tag/retention/legal-hold through the metadata-only APIs, never a re-PUT). A replica written before the ledger existed is located by exact key + ETag, minus the candidates other generations of the key already claim; an ambiguous remainder is refused with a one-hour backoff (`replication_purge_replica_unresolved`). `NoSuchVersion` on a version-addressed DELETE counts as purged. See rustfs/backlog#2340, rustfs/backlog#2085 and `docs/operations/replication-check.md` (VersionFidelity). | `replication-check`, outbound target matrix, `MintOwnVersionIds` mode (also models Wasabi's 404 `NoSuchVersion` on unknown ids) |
|
||||
| Returns an ETag that is not the content MD5 without announcing SSE | Every single-part object fails ETag verification. Set `RUSTFS_REPLICATION_REPLICA_ETAG_VERIFY=false`. | Replication status FAILED with `replica etag mismatch` |
|
||||
|
||||
## Environment knobs
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# S3 write failure diagnostics
|
||||
|
||||
**Use this when:** distinguishing occasional write failures from a node-wide outage, or interpreting cached storage inventory during an internode failure.
|
||||
|
||||
## Measure the failure ratio
|
||||
|
||||
`rustfs_s3_http_requests_total` counts external S3 HTTP outcomes independently of the configured log level. Its bounded labels are `method`, `op`, and `outcome`; the exporter target identifies the node. Bucket names, object keys, request IDs, and error text are not metric labels.
|
||||
|
||||
The counter increments exactly once when response headers are produced, the service returns an error without a response (`service_error`), or its pending future is dropped (`cancelled`). Outcomes `1xx` through `5xx` classify HTTP responses; `unknown` is reserved for a response outside those classes. A successful response header is not proof that a streamed response body reached the client. Body-stream errors remain separate streaming diagnostics.
|
||||
|
||||
The `op` label uses the existing S3 operation names, such as `s3:PutObject`. A request rejected before operation dispatch has `op="unknown"`, while retaining its HTTP method. Include these requests when measuring a node outage. Do not infer `PutObject` from `PUT` alone: bucket and multipart operations also use that method. Admin, console, health, RPC, STS, and enabled non-S3 protocol routes are excluded.
|
||||
|
||||
For example, with the usual Prometheus `instance` target label, compare the per-node PUT-method HTTP 5xx ratio:
|
||||
|
||||
```promql
|
||||
sum by (instance) (rate(rustfs_s3_http_requests_total{method="PUT",outcome="5xx"}[5m]))
|
||||
/
|
||||
sum by (instance) (rate(rustfs_s3_http_requests_total{method="PUT",outcome=~"[1-5]xx"}[5m]))
|
||||
```
|
||||
|
||||
Inspect `service_error` and `cancelled` separately; neither implies a received HTTP status. A zero denominator or absent series means no observed traffic, not proof of health. Use `rate` or reset-aware deltas because counters restart with the process. The older `rustfs_s3_operations_total` counter measures handler entries and excludes pre-dispatch rejections; it is not this HTTP denominator.
|
||||
|
||||
The authenticated admin metrics endpoint exposes the same counters through the optional `http` field in `aggregated` and `by_host`. Request `/rustfs/admin/v3/metrics?types=512&by-host=true&n=1` on each node for HTTP-only data. The default type selection also includes HTTP outcomes. This endpoint remains an NDJSON stream and does not become a cluster-wide peer fanout. `http.requests` contains `method`, `operation`, `outcome`, and `total`; `http.collected` timestamps collection. Compare consecutive samples from the same host. Concurrent snapshots are not atomic across series.
|
||||
|
||||
A missing `http` field means an older or non-reporting node, not zero failures. Old map-encoded RPC readers ignore the additive field; new readers accept older snapshots. In mixed-version deployments, check reporting coverage before aggregating a fleet-wide ratio.
|
||||
|
||||
## Interpret failed storage probes
|
||||
|
||||
Storage inventory includes an `observations` entry for each probed node. The aggregator owns this provenance even when a peer runs an older version.
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `endpoint` | Node whose local inventory was queried. |
|
||||
| `status` | `succeeded`, `failed`, or `unknown`; this is the probe result, not physical drive health. |
|
||||
| `cached` | Historical inventory was reused for this response. |
|
||||
| `last_success_unix_millis` | Wall-clock time of the last successful observation, when known. |
|
||||
| `snapshot_age_seconds` | Monotonic elapsed age since that observation, when known. |
|
||||
| `error_code` | Bounded storage error classification for a failed probe, without raw error text. |
|
||||
|
||||
After a failed probe, inventory younger than 60 seconds may retain drive identity and capacity, but returned drive `state` and `runtime_state` become `unknown` immediately. Capacity is marked as a `snapshot`; its age advances when the original observation age was known. Expired or absent inventory is synthesized from topology, with capacity observation source `missing`. Repeated polling does not extend this age budget. A successful probe replaces the historical snapshot and clears the failure streak.
|
||||
|
||||
An admin RPC timeout or authentication error is not proof of failed physical disks. It also cannot supply fresh evidence of healthy disks. Consequently, cluster health reports can become unready on the first failed probe when remaining known-online drives cannot demonstrate the existing quorum. The quorum thresholds, S3 admission gate, drive-health tracker, and metadata recovery algorithm are unchanged. Consult independent drive and transport diagnostics before replacing a disk. Legacy snapshots without observations have unknown provenance.
|
||||
|
||||
The probe round timeout is configured independently; see [Admin peer probe timeout](admin-peer-probe-timeout.md).
|
||||
|
||||
## Correlate bounded diagnostics
|
||||
|
||||
Normal operation does not require success logs at WARN. Request counters remain available with WARN logging, while existing runtime readiness diagnostics distinguish `pool_meta_write_blocked` from insufficient storage quorum. Do not clear a metadata write fence merely to make readiness green.
|
||||
|
||||
PUT storage failures retain their typed source chain internally and emit bounded S3/storage error codes, I/O kinds, and RPC status codes alongside the existing request ID, bucket, and key. Raw nested error strings and RPC metadata are not logged by this diagnostic. A repeated PUT diagnostic is limited to one event per five seconds; HTTP server-error logs are limited per status code over the same interval for accounted S3 traffic. `suppressed_errors` reports suppressed events at the next emitted event; use the HTTP counter, not log-line counts, to measure failures. HTTP server-error URI diagnostics omit query strings, including presigned credentials.
|
||||
|
||||
Storage inventory emits a WARN event on the first failed probe and an INFO event on recovery, using `event="storage_info_probe"`. A recovery event confirms the RPC succeeded, not that every reported disk is healthy. Bucket metadata load/retry errors include the bucket and a bounded error code, so one failing bucket can be identified without dumping its metadata.
|
||||
|
||||
No new environment variable, admin authorization action, or recovery command is required.
|
||||
@@ -25,6 +25,8 @@ The [scanner checkpoint fixture](scanner-checkpoint-fixture.md) diagnoses retain
|
||||
|
||||
The [scanner cache cost profile](scanner-cache-cost.md) separates clone, subtree copy, encoding, and counted save costs without changing production cache behavior.
|
||||
|
||||
The [Pool layout compatibility reference](pool-layout-compatibility.md) defines the topology and EC regression matrix for single-drive, single-node multi-drive, and multi-node expansion pools.
|
||||
|
||||
## Naming conventions
|
||||
|
||||
### Reserved test-name substrings (migration gate)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# Pool Layout Compatibility and Regression Tests
|
||||
|
||||
**Use this when:** configuring `RUSTFS_VOLUMES` for expansion, investigating issue #6186, or changing pool admission and its regression tests.
|
||||
**Source of truth:** `DisksLayout::from_volumes` and `get_set_indexes` in `crates/ecstore/src/layout/disks_layout.rs`, `EndpointServerPools::create_server_endpoints` in `crates/ecstore/src/layout/endpoints.rs`, startup format validation in `crates/ecstore/src/store/init_format.rs`, and `lookup_config_for_pools` in `crates/ecstore/src/config/storageclass.rs`. Geometry and parity invariants are owned by [erasure-coding.md](../architecture/erasure-coding.md).
|
||||
|
||||
## Notice: count drives, not just nodes
|
||||
|
||||
An erasure pool requires at least two drive endpoints. There is no additional admission rule requiring two nodes per pool or two drives per node. A single-node multi-drive pool and a multi-node pool with one drive per node may both be valid.
|
||||
|
||||
For command-line / `RUSTFS_VOLUMES` expansion:
|
||||
|
||||
- If any volume argument contains an ellipsis expression, each argument describes a separate pool and must contain an ellipsis expression. Each pool must expand to at least two distinct drive endpoints and form a valid set layout.
|
||||
- A singleton range such as `http://node{3...3}:9000/data` still describes only one drive. It cannot bypass the minimum drive count.
|
||||
- Without ellipses, all explicit endpoints describe one pool, not one pool per endpoint.
|
||||
- A single local path such as `/data` remains a supported standalone single-drive deployment. A single URL endpoint is not a valid standalone single-drive endpoint, and a single-drive pool cannot be appended to a multi-pool deployment.
|
||||
- An initialized single-node single-drive (SNSD) deployment cannot expand in place by adding endpoints or pools. Create a new multi-drive deployment and migrate data through S3 instead. Increasing the capacity of its underlying filesystem is not a pool-topology expansion and adds no redundancy.
|
||||
- An existing multi-drive pool's drive count and set width are immutable. Preserve its original endpoints and `RUSTFS_ERASURE_SET_DRIVE_COUNT` setting, then append a new pool. Changing `/data{1...4}` to `/data{1...8}` resizes the old pool; appending `/other-data{1...4}` creates a new one.
|
||||
- Multi-drive sets contain 2 through 16 drives. A pool may contain multiple sets; 16 is not a limit on total drives in a pool. Set divisibility, automatic layout symmetry, duplicate endpoints, endpoint locality, physical-disk validation, and storage-class validation still apply.
|
||||
- An explicit storage-class parity must fit every pool's set width: `parity <= drives_per_set / 2`, with `STANDARD parity >= RRS parity`. Do not silently lower an explicit parity to admit a smaller pool.
|
||||
|
||||
Topology acceptance is not a high-availability guarantee. Losing the only host of a single-node pool loses access to every shard in that pool. With a two-drive set at `EC:1`, losing one drive leaves read quorum but not write quorum. Plan failure domains and quorum separately from admission.
|
||||
|
||||
These are valid four-drive-per-set topology examples, subject to the remaining startup checks:
|
||||
|
||||
```text
|
||||
# Two pools, each with four nodes and one drive per node.
|
||||
RUSTFS_VOLUMES="http://node{1...4}:9000/data http://node{5...8}:9000/data"
|
||||
|
||||
# A four-node pool plus a single-node, four-drive pool.
|
||||
RUSTFS_VOLUMES="http://node{1...4}:9000/data http://node5:9000/data{1...4}"
|
||||
```
|
||||
|
||||
## Rejection and recovery
|
||||
|
||||
Invalid single-drive expansion arguments fail during layout parsing. When a syntactically valid layout tries to resize an initialized pool, startup compares the stored format with the configured drive count and set width before initializing or migrating formats for that pool:
|
||||
|
||||
- `UnsupportedSnsdExpansion` explains that SNSD cannot expand in place and directs the operator to restore the single local path or migrate through S3 to a new deployment.
|
||||
- `PoolTopologyMismatch` reports stored and configured drive counts and set widths, and directs the operator to restore the original pool and append a new pool instead.
|
||||
|
||||
These are permanent startup errors, not retryable quorum failures. Rejection does not rewrite the affected pool's old format or initialize its new drives. Do not delete `format.json` to bypass it. This is a per-pool check, not an atomic, read-only preflight across every pool in the deployment.
|
||||
|
||||
A healthy format quorum remains authoritative; a foreign or malformed minority is quarantined as before. Without a quorum, an unambiguous, valid observed layout can identify a topology mismatch before the wait/retry path. Conflicting observed layouts are not treated as proof of expansion. Missing disks and transient network failures alone do not establish a topology change and retain their existing handling.
|
||||
|
||||
## MinIO comparison boundary
|
||||
|
||||
The reference is MinIO Community source at commit `7aac2a2c5b7c882e68c1ce017d8256be2feea27f`, not an unversioned claim about all MinIO products or releases:
|
||||
|
||||
- [Endpoint expansion](https://github.com/minio/minio/blob/7aac2a2c5b7c882e68c1ce017d8256be2feea27f/cmd/endpoint-ellipses.go): `mergeDisksLayoutFromArgs` requires ellipses on every expansion argument, and `getSetIndexes` rejects fewer than two endpoints.
|
||||
- [Endpoint admission](https://github.com/minio/minio/blob/7aac2a2c5b7c882e68c1ce017d8256be2feea27f/cmd/endpoint.go): `CreatePoolEndpoints` does not require two nodes per pool; its standalone single-drive special case requires a local path.
|
||||
- [Pool initialization](https://github.com/minio/minio/blob/7aac2a2c5b7c882e68c1ce017d8256be2feea27f/cmd/erasure-server-pool.go): `newErasureServerPools` checks a common parity against every pool.
|
||||
- [Storage preparation](https://github.com/minio/minio/blob/7aac2a2c5b7c882e68c1ce017d8256be2feea27f/cmd/prepare-storage.go) and [format validation](https://github.com/minio/minio/blob/7aac2a2c5b7c882e68c1ce017d8256be2feea27f/cmd/format-erasure.go): persisted drive counts and set widths must match the configured pool; format-layout errors are not ordinary quorum-wait conditions. RustFS keeps its existing majority/minority handling rather than adopting MinIO's all-format validation order.
|
||||
|
||||
The node/drive admission rules above match this baseline. This reference does not claim complete startup or storage-class equivalence:
|
||||
|
||||
- RustFS resolves automatic parity independently for each pool's set width. For widths `[4, 2]`, automatic STANDARD parity resolves to `[2, 1]`. MinIO uses a common parity, initially selected from the first pool when no value is configured, and rejects a later pool that cannot accommodate it. RustFS's existing automatic policy is not changed by these regression tests.
|
||||
- An explicit STANDARD `EC:2` rejects a two- or three-drive set in RustFS; `EC:1` fits both. Explicit configuration is shared, not a user-configurable per-pool override.
|
||||
- RustFS also checks symmetry when `RUSTFS_ERASURE_SET_DRIVE_COUNT` is explicitly set. The MinIO baseline skips automatic symmetry selection for an explicit set width. The topology tests below do not establish equivalence for every explicit-width layout.
|
||||
|
||||
## Regression matrix
|
||||
|
||||
Layout tests use symbolic endpoints and fixed set-count inputs. Startup tests use temporary local drives and the production format-loading path, comparing format bytes before and after rejection. They do not require production disks, DNS records, or a running MinIO server. Storage-class tests inject configuration directly rather than mutating the process environment.
|
||||
|
||||
| Scenario | Expected result | Regression guard |
|
||||
|---|---|---|
|
||||
| Standalone `/data` | One single-drive layout | `standalone_single_drive_path_remains_supported` |
|
||||
| Standalone single URL endpoint | Reject; single-drive mode requires a local path | `test_create_pool_endpoints` |
|
||||
| Two explicit URLs, no ellipses | One pool containing both drives | `explicit_endpoints_without_ellipses_form_one_pool` |
|
||||
| Two single-node pools, each with 2 or 4 drives | Two valid pools | `pool_expansion_accepts_single_node_multi_drive_pools` |
|
||||
| Four-node, one-drive-per-node pool mixed with a single-node, four-drive pool, in either order | Both pool boundaries and set widths preserved | `pool_expansion_accepts_single_node_multi_drive_pools` |
|
||||
| Two pools with 2, 3, or 4 nodes per pool and one drive per node | One set per pool; every drive retained in its pool | `pool_expansion_accepts_multi_node_single_drive_pools` |
|
||||
| Ellipsis pool mixed with a plain single-drive endpoint, in either order | Reject with the ellipsis requirement and minimum-drive notice | `pool_expansion_rejects_plain_single_drive_pool_with_notice` |
|
||||
| Singleton host or drive range, alone or before/after another pool | Reject with the minimum-drive notice and standalone-path guidance | `pool_expansion_rejects_singleton_ellipsis_pool_with_notice` |
|
||||
| Four drives on one node or four nodes, explicit set width 2 | Two two-drive sets | `explicit_set_size_counts_drives_not_nodes` |
|
||||
| Two-drive pool, explicit set width 4 | Reject and identify the requested set width | `undersized_pool_error_identifies_requested_set_size` |
|
||||
| Credentials in rejected plain or singleton pool endpoints | Errors do not echo secrets | `layout_errors_do_not_echo_url_credentials` |
|
||||
| Mixed single-node multi-drive / multi-node single-drive pools through endpoint resolution | Distributed setup, correct node count and pool/set/disk indices | `pool_expansion_resolves_single_node_multi_drive_and_multi_node_single_drive_pools` |
|
||||
| Additional set width 2 or 3, explicit STANDARD `EC:2` | Reject and identify the incompatible pool | `explicit_standard_parity_is_validated_against_every_pool` |
|
||||
| Set widths `[4, 4]` with `EC:2`, or `[4, 2/3/4]` with `EC:1` | Shared explicit parity accepted | `explicit_standard_parity_is_validated_against_every_pool` |
|
||||
| Explicit environment STANDARD `EC:2`, widths `[4, 2]` | Reject; do not clamp parity | `explicit_environment_standard_parity_is_not_clamped` |
|
||||
| Automatic parity, widths `[4, 2]` | Preserve RustFS's existing per-pool `[2, 1]` policy | `automatic_parity_is_resolved_per_pool` |
|
||||
| Existing SNSD plus new drives, on first/non-first server | Reject with SNSD migration guidance; old format unchanged and new drives unformatted | `single_drive_format_rejects_in_place_expansion_without_writes` |
|
||||
| Existing four-drive pool resized to 2, 6, or 8 drives, or regrouped between one four-drive set and two two-drive sets | Reject with stored/configured geometry and append-pool guidance; no format writes | `existing_pool_rejects_drive_count_or_set_width_changes_without_writes` |
|
||||
| Existing four-drive pool with only one drive reachable | Retain quorum failure, not an expansion error | `subquorum_existing_layout_with_missing_drives_is_not_expansion` |
|
||||
| Conflicting four-drive and two-drive formats without a quorum | Retain quorum failure; do not infer the original topology | `conflicting_layouts_without_quorum_are_not_expansion_proof` |
|
||||
| Healthy three-drive majority with a foreign SNSD minority | Start with the majority and quarantine the outlier | `existing_format_quorum_ignores_single_drive_outlier` |
|
||||
| New four-drive pool alongside an initialized four-drive pool | Preserve the deployment ID and original format; original pool restarts | `multi_drive_pool_expansion_preserves_existing_format` |
|
||||
| Typed SNSD/topology errors versus missing-disk, network, and quorum errors | Only permanent topology/corruption errors bypass the format retry loop | `test_should_retry_format_load_rejects_permanent_topology_errors` |
|
||||
| Full store startup, SNSD to four drives or four-drive pool to eight | Return the typed topology error before retry backoff; no format writes | `store_startup_rejects_pool_resize_before_retry_loop` |
|
||||
| Startup topology error cloning and I/O wrapping | Retain error type and guidance; do not narrow into a disk/quorum error | `startup_topology_errors_preserve_identity_and_guidance` |
|
||||
|
||||
Layout and endpoint guards live in the layout source files above; parity guards live in the storage-class module. The existing `test_get_set_indexes` and `test_into_endpoint_set` tables cover larger, multi-set layouts and malformed ranges.
|
||||
|
||||
Run the focused crate tests:
|
||||
|
||||
```bash
|
||||
cargo nextest run -p rustfs-ecstore --lib \
|
||||
-E 'test(layout::disks_layout::) | test(layout::endpoints::) | test(config::storageclass::) | test(store::init_format::) | test(test_should_retry_format_load) | test(error::)'
|
||||
```
|
||||
|
||||
## Runtime coverage
|
||||
|
||||
Keep the existing single-node multi-drive pool scenarios. They are valid topologies, not exceptions that need a node-count bypass:
|
||||
|
||||
- `cluster_two_pool_smoke` in `crates/e2e_test/src/cluster_multidrive_pool_test.rs` exercises real S3 traffic against two pools.
|
||||
- `four_node_pool_expand_preserves_objects_then_rebalance` in `crates/e2e_test/src/distributed/expand_decommission_rebalance_test.rs` appends pools, verifies existing objects, restarts, and exercises rebalance.
|
||||
|
||||
The localhost harness uses separate processes and ports; it does not prove independent physical-host failure tolerance. See [distributed-e2e.md](distributed-e2e.md) for the binary, filesystem, and execution requirements before running expansion tests. Parser and endpoint unit tests establish admission, not persistent-data migration safety or production availability.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user