goosefs_sdk/cache/mod.rs
1// Copyright (C) 2026 Tencent. All rights reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Client-side local page cache.
16//!
17//! This module implements a local, page-based read cache for the Goosefs Rust
18//! SDK, mirroring the Java client's
19//! `com.qcloud.cos.goosefs.client.file.cache.*` design.
20//!
21//! # Status
22//!
23//! Implemented: the public abstractions ([`CacheManager`], [`PageId`],
24//! [`PageInfo`], [`CacheManagerOptions`]), the disabled, always-miss
25//! [`DisabledCacheManager`], and the disk-backed [`LocalCacheManager`]
26//! (multi-dir [`store::LocalPageStore`] + a `foyer` metadata/eviction cache
27//! + bounded async
28//! write-back + striped page locks). The page-split read loop lives in
29//! [`caching_reader::read_through_cache`]. See
30//! `docs/CLIENT_PAGE_CACHE_DESIGN.md` for the full design.
31//!
32//! The cache is **disabled by default** ([`crate::config::GoosefsConfig::client_cache_enabled`]
33//! defaults to `false`), so existing behaviour is unchanged unless explicitly
34//! opted in.
35//!
36//! # Architecture (target)
37//!
38//! ```text
39//! GoosefsFileInStream::read_at
40//! → CachingPositionReader (page split + hit/miss + fill)
41//! ├── cache.get() → hit (copy from local disk)
42//! └── external read (GrpcBlockReader) → miss (read + async fill)
43//! │
44//! ▼
45//! CacheManager (trait) → LocalCacheManager
46//! ├── foyer Cache per dir (metadata + eviction + byte capacity)
47//! ├── PageStore (LocalPageStore: disk IO)
48//! ├── reaper task (deletes files of evicted pages)
49//! └── Allocator (multi-dir)
50//! ```
51//!
52//! # Best-effort contract
53//!
54//! The cache is **best-effort**: a miss or any internal error must never
55//! affect read correctness — callers always fall back to reading from the
56//! worker/UFS. Errors are swallowed internally and surfaced only as
57//! `Client.Cache*Errors` metrics (mirrors Java `NoExceptionCacheManager`).
58
59mod metrics;
60mod options;
61mod page_id;
62
63pub mod allocator;
64pub mod caching_reader;
65#[cfg(feature = "page-cache")]
66pub mod manager;
67#[cfg(feature = "page-cache")]
68pub mod store;
69
70pub use allocator::{Allocator, HashAllocator};
71pub use caching_reader::{read_through_cache, ExternalRangeReader, FillMode};
72#[cfg(feature = "page-cache")]
73pub use manager::LocalCacheManager;
74pub use metrics::name as metric_name;
75pub use options::CacheManagerOptions;
76pub use page_id::{CacheScope, PageId, PageInfo};
77
78use bytes::Bytes;
79use std::sync::Arc;
80
81/// One cached page read request.
82#[derive(Debug, Clone)]
83pub struct PageReadRequest {
84 pub page_id: PageId,
85 pub page_offset: usize,
86 pub len: usize,
87}
88
89/// Whether a file may participate in the local page cache (HR-1).
90///
91/// `file_id <= 0` means the server reported no stable inode identity. The
92/// cache key namespace is `file_id.to_string()`, so a non-positive id collapses
93/// to the shared bucket `"0"` and distinct files with equal `(length, mtime)`
94/// could cross-read each other's pages. Callers must disable the page cache
95/// for such files (neither read nor fill).
96#[inline]
97pub(crate) fn page_cache_eligible(file_id: i64) -> bool {
98 file_id > 0
99}
100
101/// Operational state of a [`CacheManager`].
102///
103/// Mirrors Java `CacheManager.State`.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum CacheState {
106 /// Cache is not usable (e.g. failed to initialize). All gets miss.
107 NotInUse,
108 /// Cache can serve reads but rejects writes (e.g. recovering / read-only).
109 ReadOnly,
110 /// Cache is fully operational.
111 ReadWrite,
112}
113
114impl CacheState {
115 /// Numeric encoding for the `Client.CacheState` gauge.
116 ///
117 /// Matches Java's ordinal-style encoding: `NOT_IN_USE = 0`,
118 /// `READ_ONLY = 1`, `READ_WRITE = 2`.
119 pub fn as_i64(self) -> i64 {
120 match self {
121 CacheState::NotInUse => 0,
122 CacheState::ReadOnly => 1,
123 CacheState::ReadWrite => 2,
124 }
125 }
126}
127
128/// Local page cache abstraction.
129///
130/// Implementations coordinate the metadata/eviction cache, disk store and
131/// locking to serve cached pages. See the module docs for the best-effort
132/// contract.
133///
134/// All methods are intentionally infallible (`bool` / `usize` rather than
135/// `Result`): cache failures must never propagate as read errors.
136#[async_trait::async_trait]
137pub trait CacheManager: Send + Sync {
138 /// Store (fill) a whole page.
139 ///
140 /// `page` should be the full page bytes (≤ page size). Returns `true` if
141 /// the page was cached, `false` otherwise (e.g. cache full, racing write,
142 /// or cache not in `ReadWrite` state).
143 async fn put(&self, page_id: &PageId, page: Bytes) -> bool;
144
145 /// Schedule a best-effort cache fill that does **not** block the caller.
146 ///
147 /// The default implementation spawns a detached task that calls
148 /// [`CacheManager::put`]. Implementations with bounded async write-back
149 /// override this to apply back-pressure (rejecting fills when the
150 /// write-back pool is saturated, recording
151 /// `Client.CachePutAsyncRejectionErrors`).
152 fn schedule_fill(self: Arc<Self>, page_id: PageId, page: Bytes)
153 where
154 Self: 'static,
155 {
156 tokio::spawn(async move {
157 let _ = self.put(&page_id, page).await;
158 });
159 }
160
161 /// Read `dst.len()` bytes from page `page_id` starting at `page_offset`
162 /// into `dst`.
163 ///
164 /// Returns the number of bytes actually read. `0` means a cache miss (or
165 /// any internal error): the caller must read from the worker/UFS instead.
166 async fn get(&self, page_id: &PageId, page_offset: usize, dst: &mut [u8]) -> usize;
167
168 /// Read bytes from a cached page and return the owned [`Bytes`] directly.
169 ///
170 /// The default implementation preserves the legacy `get` contract by
171 /// reading into a caller-owned buffer. io_uring-backed implementations
172 /// override this to return the kernel-filled buffer directly, avoiding one
173 /// extra copy on cache hits.
174 async fn get_bytes(&self, page_id: &PageId, page_offset: usize, len: usize) -> Bytes {
175 if len == 0 {
176 return Bytes::new();
177 }
178 let mut dst = vec![0u8; len];
179 let n = self.get(page_id, page_offset, &mut dst).await;
180 if n == 0 {
181 Bytes::new()
182 } else {
183 dst.truncate(n);
184 Bytes::from(dst)
185 }
186 }
187
188 /// Read multiple cached pages. Each output corresponds to the request at
189 /// the same index; an empty [`Bytes`] means miss or cache error.
190 async fn get_batch_bytes(&self, requests: &[PageReadRequest]) -> Vec<Bytes> {
191 let mut out = Vec::with_capacity(requests.len());
192 for req in requests {
193 out.push(self.get_bytes(&req.page_id, req.page_offset, req.len).await);
194 }
195 out
196 }
197
198 /// Delete a single page. Returns `true` if a page was removed.
199 async fn delete(&self, page_id: &PageId) -> bool;
200
201 /// Invalidate all cached pages belonging to `file_id`.
202 ///
203 /// Used when a file is overwritten or deleted so stale pages are not
204 /// served. Implementations should treat this as best-effort.
205 async fn invalidate(&self, file_id: &str);
206
207 /// Notify the cache that a file was (re)opened with the given identity.
208 ///
209 /// Implementations compare `(length, last_modification_time_ms)` against
210 /// the version recorded for `file_id`; if they differ (the file was
211 /// overwritten while reusing the same id), all cached pages for that file
212 /// are invalidated so stale data is never served. The default
213 /// implementation is a no-op.
214 ///
215 /// **Consistency caveat (best-effort):** overwrite detection relies on the
216 /// modification-time granularity reported by the backing UFS. On a UFS that
217 /// only exposes second-level `mtime`, two writes of equal length within the
218 /// same second (and any same-`(length, mtime)` in-place overwrite) are
219 /// indistinguishable and may serve stale pages until the entry is evicted
220 /// or its TTL elapses. Use a short `client_cache_ttl` — or extend the
221 /// identity with an etag/version — where the UFS cannot guarantee
222 /// millisecond `mtime` precision.
223 async fn on_file_open(&self, _file_id: &str, _length: i64, _last_modification_time_ms: i64) {}
224
225 /// Current operational state.
226 fn state(&self) -> CacheState;
227}
228
229/// A [`CacheManager`] that caches nothing.
230///
231/// Every [`CacheManager::get`] returns `0` (miss) and every
232/// [`CacheManager::put`] returns `false`. Used as the implementation when the
233/// cache is disabled, and as a safe fallback when initialization fails.
234#[derive(Debug, Default, Clone)]
235pub struct DisabledCacheManager;
236
237#[async_trait::async_trait]
238impl CacheManager for DisabledCacheManager {
239 async fn put(&self, _page_id: &PageId, _page: Bytes) -> bool {
240 false
241 }
242
243 fn schedule_fill(self: Arc<Self>, _page_id: PageId, _page: Bytes) {
244 // No-op: nothing to cache.
245 }
246
247 async fn get(&self, _page_id: &PageId, _page_offset: usize, _dst: &mut [u8]) -> usize {
248 0
249 }
250
251 async fn delete(&self, _page_id: &PageId) -> bool {
252 false
253 }
254
255 async fn invalidate(&self, _file_id: &str) {}
256
257 fn state(&self) -> CacheState {
258 CacheState::NotInUse
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn cache_state_encoding() {
268 assert_eq!(CacheState::NotInUse.as_i64(), 0);
269 assert_eq!(CacheState::ReadOnly.as_i64(), 1);
270 assert_eq!(CacheState::ReadWrite.as_i64(), 2);
271 }
272
273 #[tokio::test]
274 async fn disabled_manager_always_misses() {
275 let mgr = DisabledCacheManager;
276 let id = PageId::new("file-1", 0);
277
278 assert!(!mgr.put(&id, Bytes::from_static(b"hello")).await);
279
280 let mut dst = [0u8; 8];
281 assert_eq!(mgr.get(&id, 0, &mut dst).await, 0);
282 assert_eq!(dst, [0u8; 8]);
283
284 assert!(!mgr.delete(&id).await);
285 mgr.invalidate("file-1").await; // no panic
286 assert_eq!(mgr.state(), CacheState::NotInUse);
287 }
288
289 /// HR-1: only strictly positive file ids may key the page cache.
290 #[test]
291 fn page_cache_eligible_requires_positive_file_id() {
292 assert!(!page_cache_eligible(0), "file_id=0 must disable cache");
293 assert!(
294 !page_cache_eligible(-1),
295 "negative file_id must disable cache"
296 );
297 assert!(page_cache_eligible(1));
298 assert!(page_cache_eligible(i64::MAX));
299 }
300}