Skip to main content

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}