diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index db41900..47b4aad 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -110,7 +110,7 @@ The source Region stays pinned until accepted replacement writes finish. Conditi ### I/O -Reads and writes use independent bounded engine pools. Reclaim has separate read lanes. POSIX uses positioned worker I/O; optional io_uring uses fixed-depth rings. Buffered I/O is the default. Direct mode aligns runtime record I/O and keeps control, recovery, and unavoidable remainder operations buffered. Locks cover bounded in-memory work and release before device I/O. +Reads and writes use independent bounded engine pools. Reclaim has separate read lanes. POSIX uses positioned worker I/O; optional io_uring uses fixed-depth rings. Ring count and in-flight depth are independent: extra rings split the same admission bound and do not add slots. Buffered I/O is the default. Direct mode aligns runtime record I/O and keeps control, recovery, and unavoidable remainder operations buffered. SQPOLL and IOPOLL are advanced per-pool opt-ins with different requirements; they stay off until selected explicitly. Locks cover bounded in-memory work and release before device I/O. Each lane uses one concrete `IoEngine` for admission, submission, cancellation, statistics, and shutdown. Driver-specific constructors start POSIX workers or an io_uring driver behind the same bounded command and completion protocol. Callers share the engine through `Arc`; its final owner joins the workers. Submitted requests retain their buffers and capacity until actual completion, independently of the caller's wait deadline. diff --git a/BENCHMARK.md b/BENCHMARK.md index 743e996..9319c62 100644 --- a/BENCHMARK.md +++ b/BENCHMARK.md @@ -108,19 +108,19 @@ Repeat sizes in `CACHE_SOAK_VALUE_BYTES` to weight a production distribution. Us | Backend | Variable suffix | Default (read / write / reclaim) | | --------- | -------------------------------- | --------------------------------- | | POSIX | `_POSIX__WORKERS` | 4 / 4 / 1 | -| io_uring | `_IO_URING__RINGS` | 4 / 4 / 1 | -| io_uring | `_IO_URING__MAX_IN_FLIGHT` | 256 / 256 / 1 | +| io_uring | `_IO_URING__RINGS` | 1 / 1 / 1 | +| io_uring | `_IO_URING__MAX_IN_FLIGHT` | 64 / 64 / 1 | | io_uring | `_IO_URING__IOPOLL` | false | | io_uring | `_IO_URING__SQPOLL_MS` | absent (disabled) | | io_uring | `_IO_URING__SQPOLL_CPU` | absent (unpinned) | Optional fill admission uses `_FILL_CONTROL=disabled|observe|adaptive` under the same three prefixes. Enabled modes require explicit `_FILL_BYTES_PER_SECOND` and `_FILL_OPERATIONS_PER_SECOND` ceilings. The harness prints the effective options and adds a `type=fill_control` report beside cache records. Compare the modes in alternating order with identical ceilings and traffic; record rejected and hypothetical fills alongside completed throughput. High ceilings help measure instrumentation overhead, while lower ceilings and injected storage stalls exercise admission behavior. Buffered macOS results do not qualify Linux NVMe or cgroup throttling. -Select the backend with `_IO_ENGINE=posix|io-uring`. POSIX workers bound concurrent operations. io_uring ring count and aggregate in-flight limit are independent; changing one does not rewrite the other. Ring count must not exceed the in-flight limit. IOPOLL requires `_IO_MODE=direct`; SQPOLL CPU requires an idle timeout. Only the selected backend's settings are read. Each harness prints the resulting `IoEngineOptions`, and buffer estimates use its actual concurrency. The request benchmark's default read-wait capacity follows the selected read pool's in-flight limit. POSIX Immediate L2 reads still cap `CACHE_BENCH_CLIENTS` at the POSIX read-worker count, so a worker sweep must set clients at least as high as the worker count. +Select the backend with `_IO_ENGINE=posix|io-uring`. POSIX workers bound concurrent operations. io_uring ring count and aggregate in-flight limit are independent; changing one does not rewrite the other. Start with one ring per pool and set `_IO_URING_READ_MAX_IN_FLIGHT` to the concurrent L2 get count (`CACHE_BENCH_CLIENTS` in the request benchmark). Leave write and reclaim unset so they follow library defaults. Extra rings split the same depth and do not add slots. Ring count must not exceed the in-flight limit. SQPOLL and IOPOLL environment variables are advanced follow-ups with different requirements: IOPOLL requires `_IO_MODE=direct`; SQPOLL CPU requires an idle timeout. Do not enable them in the same sweep as rings or in-flight depth, and do not enable both as a pair. Only the selected backend's settings are read. Each harness prints the resulting `IoEngineOptions`, and buffer estimates use its actual concurrency. The request benchmark's default read-wait capacity follows the selected read pool's in-flight limit. POSIX Immediate L2 reads still cap `CACHE_BENCH_CLIENTS` at the POSIX read-worker count, so a worker sweep must set clients at least as high as the worker count. `CACHE_BENCH_STATS` is now `CACHE_BENCH_ACTIVITY_COUNTERS`. Machine-readable reports use `version=2`, renaming the cache record field `statistics_enabled` to `activity_counters_enabled`; the counter population is unchanged. -The previous `_READ_IO_WORKERS`, `_WRITE_IO_WORKERS`, and `_RECLAIM_WORKERS` variables are rejected with a migration error. For POSIX, use `_POSIX__WORKERS`. For io_uring, specify ring count and total in-flight depth separately; to reproduce a previous non-default read/write worker value of N, use N rings and 64 × N in-flight requests. The default effective topology is unchanged. +The previous `_READ_IO_WORKERS`, `_WRITE_IO_WORKERS`, and `_RECLAIM_WORKERS` variables are rejected with a migration error. For POSIX, use `_POSIX__WORKERS`. For io_uring, specify ring count and total in-flight depth separately. Unset io_uring knobs follow `IoUringOptions::default()`: one ring and 64 in-flight requests for read and write, and one ring and one in-flight request for reclaim. To reproduce a previous POSIX-shaped default of 4 workers, set 4 rings and 256 in-flight requests. Capacity variables follow the library's terms: `_CAPACITY_MIB` is L2, `_L1_CAPACITY_MIB` is L1, `_MANAGED_MEMORY_LIMIT_MIB` is the overall managed-memory budget, and `_REGION_SIZE_MIB` selects Region size where supported. The former `_MEMORY_MIB`, `_L1_MIB`, `_L2_MIB`, and `_REGION_MIB` names are rejected with their replacements. `recovery_scale` uses the same L1 naming under `CACHE_RECOVERY`. @@ -132,7 +132,7 @@ Capacity variables follow the library's terms: `_CAPACITY_MIB` is L2, `_L1_CAPAC /var/tmp/cache2-qualification ``` -The runner records the revision, machine, filesystem, block device, complete configuration, raw logs, medians, and checksums. It exercises buffered and Direct POSIX I/O, a POSIX Direct worker sweep with matching client depth, an experimental io_uring Direct in-flight sweep (one ring at 16/32/64 concurrent gets), mixed turnover, and final warm recovery. Qualification builds with `--features io-uring` and sets `CACHE_BENCH_ACTIVITY_COUNTERS=true` so `result phase=read_io` records in-flight peak, slot wait, payload bytes, and read amplification. IOPOLL is an optional follow-up (`CACHE_BENCH_IO_URING_READ_IOPOLL=true` with Direct I/O), not part of the default matrix. +The runner records the revision, machine, filesystem, block device, complete configuration, raw logs, medians, and checksums. It exercises buffered and Direct POSIX I/O, a POSIX Direct worker sweep with matching client depth, an experimental io_uring Direct in-flight sweep (one ring at 16/32/64 concurrent gets), mixed turnover, and final warm recovery. Qualification builds with `--features io-uring` and sets `CACHE_BENCH_ACTIVITY_COUNTERS=true` so `result phase=read_io` records in-flight peak, slot wait, payload bytes, and read amplification. SQPOLL and IOPOLL are advanced host follow-ups, not part of the default matrix; IOPOLL still needs `CACHE_BENCH_IO_URING_READ_IOPOLL=true` with Direct I/O. A release pass requires: diff --git a/CHANGELOG.md b/CHANGELOG.md index 42a7966..5bf1cd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Improvements - Background write and reclaim timeouts now enter a reversible `CacheHealth::Recovering` state: new cache fills return overload while existing reads and deletes remain available. Original requests keep their bounded buffers and Regions and are never resubmitted; all affected work must complete validation and publication before fills resume. `RuntimeOptions::io_recovery_timeout` defaults to `None` for recovery until completion or close; use `Some(duration)` to bound recovery or `Some(Duration::ZERO)` for immediate cancellation. Recovery checks at fixed one-second intervals. Close interrupts recovery and preserves the existing unfenced-write safeguards; drain may wait indefinitely. Actual I/O errors and invalid completions remain terminal. +- The I/O source for an unavailable `IoEngineOptions::IoUring` selection now names the missing crate feature, operating system, or architecture. ## v0.5.0 (2026-09-16) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 7654aab..c4202f6 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -269,26 +269,34 @@ Buffered POSIX I/O is the portable production baseline. It benefits from the ker Direct mode is Linux-only and requires `O_DIRECT` for aligned record I/O. It reduces page-cache duplication but can amplify small reads and exposes aligned I/O failures instead of silently falling back. Control and recovery operations remain buffered. Necessarily unaligned runtime remainders use the buffered compatibility path. -io_uring is feature-gated and experimental. Its three pools configure physical rings and aggregate execution bounds independently: +io_uring is feature-gated and experimental. Enable `cache2` with `features = ["io-uring"]` on a supported Linux target. `RuntimeOptions::io_mode` stays Buffered unless Direct is chosen separately. Start from `IoUringOptions::default()` and set only the read depth: ```rust -use cache2::{ - IoEngineOptions, IoMode, IoUringOptions, IoUringSqPollOptions, RuntimeOptions, -}; +use cache2::{IoEngineOptions, IoUringOptions, RuntimeOptions}; -let mut sq_poll = IoUringSqPollOptions::new(2_000); -sq_poll.cpu = Some(4); let mut io = IoUringOptions::default(); -io.read.max_in_flight = 128; -io.read.sq_poll = Some(sq_poll); +io.read.max_in_flight = 32; let mut runtime = RuntimeOptions::default(); runtime.io_engine = IoEngineOptions::IoUring(io); -runtime.io_mode = IoMode::Direct; ``` -`rings` controls driver-thread and kernel-ring count. `max_in_flight` is the aggregate admission bound and is divided as evenly as possible across those rings. SQPOLL's idle value is milliseconds; optional CPU affinity applies to each ring in that pool. SQPOLL defaults off; requested flags fail explicitly when the kernel cannot provide them. +`rings` is driver-thread and kernel-ring count. `max_in_flight` is the aggregate admission bound and is divided as evenly as possible across those rings. Extra rings do not add execution slots. POSIX `read_workers` is both thread count and depth; map that depth to `read.max_in_flight` with `rings = 1`, not to `rings`. Configuration construction fails with `ErrorKind::Unsupported` if the crate feature, OS, or architecture is missing; the I/O source names the requirement. Kernel opcode and NODROP checks happen at open. + +| Pool | `rings` | `max_in_flight` | Pattern | +| ------- | ------- | --------------------------------------- | ---------------------------------------------------- | +| Read | 1 | Concurrent L2 `get` futures | Start here; this is admission depth | +| Write | 1 | Leave the default 64 | Sequential fill; do not copy the read depth | +| Reclaim | 1 | Leave the default 1 | Raise only for observed Free-Region lag | + +Keep `rings = 1` until a single driver thread is CPU-bound, then raise `rings` while holding `max_in_flight` fixed. `rings` must not exceed that pool's in-flight limit; each ring receives a floor or ceiling share of the same depth. Under `ReadAdmission::Immediate`, extra gets beyond read depth are busy misses, so do not set `max_in_flight` far above caller concurrency. + +### Advanced io_uring polling -IOPOLL is an additional explicit per-pool opt-in through the `IoUringPoolOptions::io_poll` field. It requires `IoMode::Direct` and a filesystem and block device that support polling. While requests are outstanding the driver busy-polls the device and consumes CPU, and a cancellation stays advisory until the polled operation completes, so profile IOPOLL on the target host before adopting it. +Leave `IoUringPoolOptions::sq_poll` and `io_poll` off. They are independent advanced flags with different requirements and are easy to combine incorrectly; do not enable either while sweeping rings or in-flight depth, and do not turn both on because one of them helped. + +SQPOLL starts a kernel submission thread. The idle value is milliseconds. Optional CPU affinity applies to every ring in that pool, so a multi-ring pool pins every polling thread to the same CPU. SQPOLL does not require Direct I/O. Open fails if the kernel lacks non-fixed SQPOLL files or cannot provide the requested flags. + +IOPOLL busy-polls completions and consumes CPU while requests are outstanding. It requires `IoMode::Direct` and a filesystem and block device that support polling; configuration construction returns `InvalidInput` otherwise. Cancellation stays advisory until the polled operation completes. Profile IOPOLL alone on the target host. ### Statistics @@ -367,11 +375,17 @@ Use this profile when the data set exceeds host RAM and the goal is device behav - Keep Buffered POSIX as the portable production default; switch to Direct only after measuring amplification and warm-close cost on the target filesystem. - Size POSIX `read_workers` to the application's concurrent L2 get depth. Worker count is both thread count and admission depth. -- For experimental io_uring, start with one read ring and set `max_in_flight` to that same concurrent get depth. Extra rings do not add execution slots. +- For experimental io_uring, do not scale `rings` with vCPU count. Keep one read ring and set `read.max_in_flight` to concurrent L2 gets. Leave write and reclaim at their defaults. - Keep write execution modest; more write workers cannot exceed sequential device fill. -- Profile IOPOLL separately with `IoUringPoolOptions::io_poll` and Direct I/O on a polling-capable filesystem; do not enable it from ring or in-flight sweeps. +- Leave SQPOLL and IOPOLL off during ring and in-flight sweeps. They are independent advanced flags; profile at most one of them, and only after depth is set. IOPOLL still requires Direct I/O on a polling-capable filesystem. - Watch `requests_in_flight_peak`, `slot_wait_ns`, `l2_read_busy_misses`, and Direct versus served-byte amplification instead of throughput alone. +Keep the library default of one ring per pool. Set `read.max_in_flight` to concurrent L2 gets; use 32 or 64 when that is the caller depth, and lower it when the caller issues fewer gets. Do not raise `rings` with vCPU count. Leave write and reclaim at their defaults. Leave SQPOLL and IOPOLL off. + +| Read rings | Read `max_in_flight` | Write / reclaim | SQPOLL / IOPOLL | +| ---------- | ---------------------------- | ---------------------------------- | --------------- | +| 1 | Concurrent L2 gets, 32 or 64 | Library default (1 / 64 and 1 / 1) | Off | + ## Diagnostic map Use `Cache::snapshot()` for regular telemetry and `Cache::detailed_snapshot()` for periodic diagnosis. diff --git a/README.md b/README.md index 3feddc7..6d95455 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,22 @@ Changing the append-shard count rebinds recovered Active Regions during a warm o The default `ReadAdmission::Immediate` returns a miss under read-engine or buffer pressure. `ReadAdmission::Wait` enables a queue bounded by `max_waiters` and a positive `timeout`. Queued requests retain their read descriptor and allocate a buffer after admission. Queue saturation, memory pressure, and timeout return explicit overload. -Buffered POSIX I/O is the production path. Direct I/O is an explicit Linux mode. io_uring requires the `io-uring` feature and remains experimental; its ring count and aggregate in-flight limit are independent, so size `max_in_flight` (or POSIX `read_workers`) to concurrent L2 gets rather than adding rings. SQPOLL and IOPOLL are explicit per-pool opt-ins: SQPOLL adds kernel submission polling with configurable idle time and optional CPU affinity, while IOPOLL adds completion polling and requires direct I/O on polling-capable storage. +Buffered POSIX I/O is the production path. Direct I/O is an explicit Linux mode. io_uring requires the `io-uring` crate feature on a supported Linux target and remains experimental. Keep one ring per pool; set `read.max_in_flight` to concurrent L2 gets, and leave write and reclaim at their defaults. Extra rings split the same depth across driver threads and do not add slots. + +```toml +cache2 = { version = "0.5", features = ["io-uring"] } +``` + +```rust +use cache2::{IoEngineOptions, IoUringOptions, RuntimeOptions}; + +let mut io = IoUringOptions::default(); +io.read.max_in_flight = 32; // concurrent L2 get() futures +let mut runtime = RuntimeOptions::default(); +runtime.io_engine = IoEngineOptions::IoUring(io); +``` + +Configuration construction fails with `ErrorKind::Unsupported` when the feature, OS, or architecture is missing, and the I/O source names which one. SQPOLL and IOPOLL are advanced per-pool flags with different requirements; leave both off unless a host profile needs one of them. See [I/O engine and mode](CONFIGURATION.md#io-engine-and-mode). ### Platform support diff --git a/benchmarks/src/config.rs b/benchmarks/src/config.rs index 67daa01..53d7a37 100644 --- a/benchmarks/src/config.rs +++ b/benchmarks/src/config.rs @@ -50,9 +50,9 @@ pub fn io_engine_from_env(prefix: &str) -> io::Result { } "io-uring" => { let mut options = IoUringOptions::default(); - options.read = io_uring_pool(prefix, "READ", 4, 256)?; - options.write = io_uring_pool(prefix, "WRITE", 4, 256)?; - options.reclaim = io_uring_pool(prefix, "RECLAIM", 1, 1)?; + options.read = io_uring_pool(prefix, "READ", options.read)?; + options.write = io_uring_pool(prefix, "WRITE", options.write)?; + options.reclaim = io_uring_pool(prefix, "RECLAIM", options.reclaim)?; Ok(IoEngineOptions::IoUring(options)) } value => Err(invalid(format!("unsupported {name}: {value}"))), @@ -196,13 +196,12 @@ pub fn parse_l1_eviction_policy(name: &str) -> io::Result { fn io_uring_pool( prefix: &str, role: &str, - rings: usize, - max_in_flight: usize, + mut options: IoUringPoolOptions, ) -> io::Result { let prefix = format!("{prefix}_IO_URING_{role}"); - let mut options = IoUringPoolOptions::default(); - options.rings = setting(&format!("{prefix}_RINGS"))?.unwrap_or(rings); - options.max_in_flight = setting(&format!("{prefix}_MAX_IN_FLIGHT"))?.unwrap_or(max_in_flight); + options.rings = setting(&format!("{prefix}_RINGS"))?.unwrap_or(options.rings); + options.max_in_flight = + setting(&format!("{prefix}_MAX_IN_FLIGHT"))?.unwrap_or(options.max_in_flight); options.io_poll = env_bool(&format!("{prefix}_IOPOLL"), false)?; let idle = setting(&format!("{prefix}_SQPOLL_MS"))?; let cpu = setting(&format!("{prefix}_SQPOLL_CPU"))?; diff --git a/cache2/ERRORS.md b/cache2/ERRORS.md index c7567fe..0f9ba0f 100644 --- a/cache2/ERRORS.md +++ b/cache2/ERRORS.md @@ -39,7 +39,7 @@ With `FillControlOptions::Adaptive`, new fills also return `Overloaded` when the | `ErrorKind` | Meaning | Usual response | | --- | --- | --- | | `InvalidInput` | Storage/runtime options or a request key/value is invalid. | Fix the input; retrying it unchanged cannot succeed. | -| `Unsupported` | The selected I/O engine, mode, or platform capability is unavailable. | Select a supported configuration or build target. | +| `Unsupported` | The selected I/O engine, mode, or platform capability is unavailable. | Select a supported configuration or build target; inspect the I/O source for the missing requirement. | | `Busy` | `open` could not acquire exclusive ownership of the cache files. | Coordinate ownership or retry later with a bound. | | `Overloaded` | A bounded request-path slot, queue, buffer, or deadline is exhausted. | Fall through to the authoritative path or retry with a bound. | | `ResourceExhausted` | Startup or lifecycle work could not satisfy a required allocation/resource requirement. | Reduce the configured footprint or provide more resources. | diff --git a/cache2/src/config/runtime.rs b/cache2/src/config/runtime.rs index 390ed74..071af4b 100644 --- a/cache2/src/config/runtime.rs +++ b/cache2/src/config/runtime.rs @@ -75,22 +75,35 @@ impl Default for PosixIoOptions { /// experimental io_uring engine. /// /// Start with [`Self::default`] and assign fields before building [`CacheConfig`]. -/// `max_in_flight` is distributed as evenly as possible across `rings`. This -/// keeps admission capacity independent of the number of driver threads. +/// Keep [`Self::rings`] at 1 and size [`Self::max_in_flight`] to concurrent work +/// for this pool. Extra rings split the same depth across driver threads; they +/// do not add execution slots. Leave [`Self::sq_poll`] and [`Self::io_poll`] +/// unset unless a host profile needs one of them; they are independent advanced +/// flags and are easy to combine incorrectly. #[non_exhaustive] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct IoUringPoolOptions { - /// Number of independent rings and driver threads. Defaults to 1. + /// Independent rings and driver threads. Defaults to 1. + /// + /// Raise this only after `max_in_flight` matches caller concurrency and a + /// single driver thread is CPU-bound. Must not exceed `max_in_flight`. pub rings: usize, - /// Aggregate maximum number of in-flight requests. Defaults to 64. + /// Aggregate maximum in-flight requests. Defaults to 64. + /// + /// This is the admission bound. It is distributed as evenly as possible + /// across `rings`. For the read pool, set it to concurrent L2 gets. pub max_in_flight: usize, - /// Kernel submission queue polling for every ring in this pool. Defaults to - /// `None`, which disables polling. + /// Advanced kernel submission-queue polling for every ring in this pool. + /// + /// Defaults to `None`, which disables SQPOLL. SQPOLL does not require + /// Direct I/O. Optional CPU affinity applies to every ring in the pool. pub sq_poll: Option, - /// Completion polling for every ring in this pool. Defaults to false. + /// Advanced completion polling for every ring in this pool. /// - /// I/O polling consumes CPU while waiting and requires direct I/O on a - /// filesystem and block device that support polling. + /// Defaults to false. Requires Direct I/O on a polling-capable filesystem + /// and block device. Consumes CPU while requests are outstanding, and a + /// cancellation stays advisory until the polled operation completes. + /// Independent of [`Self::sq_poll`]. pub io_poll: bool, } @@ -108,7 +121,9 @@ impl Default for IoUringPoolOptions { /// Unchecked kernel submission queue polling parameters for the experimental /// io_uring engine. /// -/// Start with [`Self::new`] and assign fields before building [`CacheConfig`]. +/// This is an advanced opt-in. Start with [`Self::new`] and assign fields +/// before building [`CacheConfig`]. For a multi-ring pool, every ring's +/// polling thread uses the same optional CPU affinity. #[non_exhaustive] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct IoUringSqPollOptions { @@ -133,6 +148,9 @@ impl IoUringSqPollOptions { /// write, and reclaim pools. /// /// Start with [`Self::default`] and assign fields before building [`CacheConfig`]. +/// Keep one ring per pool. Set [`IoUringPoolOptions::max_in_flight`] on the read +/// pool to concurrent L2 gets; leave write and reclaim at their defaults unless +/// write slot wait or reclaim lag is the limiter. #[non_exhaustive] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct IoUringOptions { @@ -168,7 +186,9 @@ pub enum IoEngineOptions { /// bounds. /// /// Its API, configuration, and runtime behavior may change between - /// releases. + /// releases. Keep one ring per pool and size read `max_in_flight` to + /// concurrent L2 gets. SQPOLL and IOPOLL are advanced per-pool opt-ins + /// with different requirements. /// /// This variant is available only with the `io-uring` crate feature on a /// supported Linux target. @@ -185,21 +205,34 @@ impl IoEngineOptions { const fn is_available(self) -> bool { match self { Self::Posix(_) => true, - Self::IoUring(_) => cfg!(all( - feature = "io-uring", - target_os = "linux", - any( - target_arch = "x86_64", - target_arch = "aarch64", - target_arch = "riscv64", - target_arch = "loongarch64", - target_arch = "powerpc64" - ) - )), + Self::IoUring(_) => io_uring_unavailability().is_none(), } } } +const IO_URING_UNAVAILABLE_FEATURE: &str = "io_uring requires the io-uring crate feature"; +const IO_URING_UNAVAILABLE_PLATFORM: &str = "io_uring is unavailable on this platform"; +const IO_URING_UNAVAILABLE_ARCHITECTURE: &str = "io_uring is unavailable on this architecture"; + +/// Names the missing io_uring requirement, if any, for this build and target. +pub(crate) const fn io_uring_unavailability() -> Option<&'static str> { + if cfg!(not(target_os = "linux")) { + Some(IO_URING_UNAVAILABLE_PLATFORM) + } else if cfg!(not(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64", + target_arch = "loongarch64", + target_arch = "powerpc64" + ))) { + Some(IO_URING_UNAVAILABLE_ARCHITECTURE) + } else if cfg!(not(feature = "io-uring")) { + Some(IO_URING_UNAVAILABLE_FEATURE) + } else { + None + } +} + // Pool topology owns aggregate bounds; each engine config contains one instance's // execution parameters. Both accounting and construction derive from this shape. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -593,7 +626,7 @@ impl RuntimeOptions { if !self.io_engine.is_available() { return Err(io::Error::new( io::ErrorKind::Unsupported, - "io_uring is unavailable on this build or platform", + io_uring_unavailability().expect("unavailable io_uring names a reason"), )); } if !self.io_mode.is_available() { @@ -1119,6 +1152,56 @@ mod tests { } } + #[test] + fn io_uring_unavailability_names_the_missing_requirement() { + #[cfg(not(target_os = "linux"))] + assert_eq!( + io_uring_unavailability(), + Some(IO_URING_UNAVAILABLE_PLATFORM) + ); + #[cfg(all( + target_os = "linux", + not(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64", + target_arch = "loongarch64", + target_arch = "powerpc64" + )) + ))] + assert_eq!( + io_uring_unavailability(), + Some(IO_URING_UNAVAILABLE_ARCHITECTURE) + ); + #[cfg(all( + target_os = "linux", + any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64", + target_arch = "loongarch64", + target_arch = "powerpc64" + ), + not(feature = "io-uring") + ))] + assert_eq!( + io_uring_unavailability(), + Some(IO_URING_UNAVAILABLE_FEATURE) + ); + #[cfg(all( + feature = "io-uring", + target_os = "linux", + any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64", + target_arch = "loongarch64", + target_arch = "powerpc64" + ) + ))] + assert_eq!(io_uring_unavailability(), None); + } + #[cfg(all( feature = "io-uring", target_os = "linux", diff --git a/cache2/src/io/engine/mod.rs b/cache2/src/io/engine/mod.rs index f0570cb..d21890c 100644 --- a/cache2/src/io/engine/mod.rs +++ b/cache2/src/io/engine/mod.rs @@ -2138,7 +2138,8 @@ pub fn build_file_engine( let _ = config; Err(io::Error::new( io::ErrorKind::Unsupported, - "io_uring is unavailable on this build or platform", + crate::config::runtime::io_uring_unavailability() + .expect("unavailable io_uring names a reason"), )) } } diff --git a/tests-integration/tests/cache.rs b/tests-integration/tests/cache.rs index bd0f595..65653b6 100644 --- a/tests-integration/tests/cache.rs +++ b/tests-integration/tests/cache.rs @@ -43,7 +43,17 @@ use cache2::ErrorOperation; use cache2::IoEngineOptions; #[cfg(not(target_os = "linux"))] use cache2::IoMode; -#[cfg(not(target_os = "linux"))] +#[cfg(not(all( + feature = "io-uring", + target_os = "linux", + any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64", + target_arch = "loongarch64", + target_arch = "powerpc64" + ) +)))] use cache2::IoUringOptions; use cache2::L1EvictionPolicy; use cache2::PosixIoOptions; @@ -511,6 +521,32 @@ fn unavailable_io_engine_is_rejected_before_file_creation() { assert_eq!(error.kind(), ErrorKind::Unsupported); assert_eq!(error.operation(), ErrorOperation::BuildConfig); assert_eq!(error.io_kind(), io::ErrorKind::Unsupported); + let message = error.as_io_error().to_string(); + #[cfg(not(target_os = "linux"))] + assert_eq!(message, "io_uring is unavailable on this platform"); + #[cfg(all( + target_os = "linux", + not(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64", + target_arch = "loongarch64", + target_arch = "powerpc64" + )) + ))] + assert_eq!(message, "io_uring is unavailable on this architecture"); + #[cfg(all( + target_os = "linux", + any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "riscv64", + target_arch = "loongarch64", + target_arch = "powerpc64" + ), + not(feature = "io-uring") + ))] + assert_eq!(message, "io_uring requires the io-uring crate feature"); files.assert_absent(); }