Skip to main content

a3s_vec/
storage_ceilings.rs

1//! Typed persistence `DoS` ceilings for corpus-scale artifacts.
2//!
3//! These bounds are explicit product policy, not host autodetection. Defaults
4//! are sized for million-document workstation corpora while remaining finite.
5//! Protocol/format caps (manifest, single WAL frame, lock owner) stay
6//! hardcoded elsewhere.
7
8use crate::error::{Error, Result};
9use serde::{Deserialize, Deserializer, Serialize};
10
11/// Default document-snapshot write/recovery ceiling (8 GiB).
12pub const DEFAULT_SNAPSHOT_BYTES: u64 = 8 * 1024 * 1024 * 1024;
13/// Default derived-index-cache payload ceiling (8 GiB).
14pub const DEFAULT_INDEX_CACHE_BYTES: u64 = 8 * 1024 * 1024 * 1024;
15/// Extra allowance for the on-disk index-cache framing beyond the payload.
16pub const INDEX_CACHE_FILE_OVERHEAD_BYTES: u64 = 4_096;
17/// Default committed WAL replay ceiling (8 GiB).
18pub const DEFAULT_WAL_REPLAY_BYTES: u64 = 8 * 1024 * 1024 * 1024;
19/// Default `DiskANN` sidecar file ceiling (512 MiB).
20pub const DEFAULT_DISKANN_FILE_BYTES: u64 = 512 * 1024 * 1024;
21
22/// Explicit ceilings for durable corpus artifacts.
23///
24/// Callers raise or tighten these through [`crate::CollectionOptions`] or
25/// process [`crate::ConfigBuilder`]. The engine never invents values from
26/// host RAM or free disk.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
28#[allow(clippy::struct_field_names)]
29pub struct StorageCeilings {
30    #[serde(rename = "max_snapshot_bytes")]
31    snapshot_bytes: u64,
32    #[serde(rename = "max_index_cache_bytes")]
33    index_cache_bytes: u64,
34    #[serde(rename = "max_wal_replay_bytes")]
35    wal_replay_bytes: u64,
36    #[serde(rename = "max_diskann_file_bytes")]
37    diskann_file_bytes: u64,
38}
39
40#[derive(Deserialize, Default)]
41#[serde(default, deny_unknown_fields)]
42#[allow(clippy::struct_field_names)]
43struct StorageCeilingsWire {
44    #[serde(rename = "max_snapshot_bytes")]
45    snapshot_bytes: Option<u64>,
46    #[serde(rename = "max_index_cache_bytes")]
47    index_cache_bytes: Option<u64>,
48    #[serde(rename = "max_wal_replay_bytes")]
49    wal_replay_bytes: Option<u64>,
50    #[serde(rename = "max_diskann_file_bytes")]
51    diskann_file_bytes: Option<u64>,
52}
53
54impl<'de> Deserialize<'de> for StorageCeilings {
55    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
56    where
57        D: Deserializer<'de>,
58    {
59        let wire = StorageCeilingsWire::deserialize(deserializer)?;
60        let mut ceilings = Self::default();
61        if let Some(limit) = wire.snapshot_bytes {
62            ceilings = ceilings
63                .try_with_max_snapshot_bytes(limit)
64                .map_err(serde::de::Error::custom)?;
65        }
66        if let Some(limit) = wire.index_cache_bytes {
67            ceilings = ceilings
68                .try_with_max_index_cache_bytes(limit)
69                .map_err(serde::de::Error::custom)?;
70        }
71        if let Some(limit) = wire.wal_replay_bytes {
72            ceilings = ceilings
73                .try_with_max_wal_replay_bytes(limit)
74                .map_err(serde::de::Error::custom)?;
75        }
76        if let Some(limit) = wire.diskann_file_bytes {
77            ceilings = ceilings
78                .try_with_max_diskann_file_bytes(limit)
79                .map_err(serde::de::Error::custom)?;
80        }
81        Ok(ceilings)
82    }
83}
84
85impl Default for StorageCeilings {
86    fn default() -> Self {
87        Self {
88            snapshot_bytes: DEFAULT_SNAPSHOT_BYTES,
89            index_cache_bytes: DEFAULT_INDEX_CACHE_BYTES,
90            wal_replay_bytes: DEFAULT_WAL_REPLAY_BYTES,
91            diskann_file_bytes: DEFAULT_DISKANN_FILE_BYTES,
92        }
93    }
94}
95
96impl StorageCeilings {
97    /// Product defaults for a new policy object.
98    pub fn new() -> Self {
99        Self::default()
100    }
101
102    /// Caps one atomic document snapshot write or recovery.
103    pub fn try_with_max_snapshot_bytes(mut self, limit: u64) -> Result<Self> {
104        self.snapshot_bytes = positive_limit(limit, "max_snapshot_bytes")?;
105        Ok(self)
106    }
107
108    /// Caps the derived index-cache payload (HNSW/scalar/FTS blob).
109    pub fn try_with_max_index_cache_bytes(mut self, limit: u64) -> Result<Self> {
110        self.index_cache_bytes = positive_limit(limit, "max_index_cache_bytes")?;
111        Ok(self)
112    }
113
114    /// Caps committed WAL bytes replayed during open/recovery.
115    pub fn try_with_max_wal_replay_bytes(mut self, limit: u64) -> Result<Self> {
116        self.wal_replay_bytes = positive_limit(limit, "max_wal_replay_bytes")?;
117        Ok(self)
118    }
119
120    /// Caps one `DiskANN` sidecar file.
121    pub fn try_with_max_diskann_file_bytes(mut self, limit: u64) -> Result<Self> {
122        self.diskann_file_bytes = positive_limit(limit, "max_diskann_file_bytes")?;
123        Ok(self)
124    }
125
126    pub fn max_snapshot_bytes(self) -> u64 {
127        self.snapshot_bytes
128    }
129
130    pub fn max_index_cache_bytes(self) -> u64 {
131        self.index_cache_bytes
132    }
133
134    /// On-disk index-cache file ceiling, including framing overhead.
135    pub fn max_index_cache_file_bytes(self) -> u64 {
136        self.index_cache_bytes
137            .saturating_add(INDEX_CACHE_FILE_OVERHEAD_BYTES)
138    }
139
140    pub fn max_wal_replay_bytes(self) -> u64 {
141        self.wal_replay_bytes
142    }
143
144    pub fn max_diskann_file_bytes(self) -> u64 {
145        self.diskann_file_bytes
146    }
147}
148
149fn positive_limit(limit: u64, name: &str) -> Result<u64> {
150    if limit == 0 {
151        return Err(Error::invalid_argument(format!(
152            "{name} must be positive; omit the field to keep the product default"
153        )));
154    }
155    Ok(limit)
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn defaults_match_published_product_ceilings() {
164        let ceilings = StorageCeilings::default();
165        assert_eq!(ceilings.max_snapshot_bytes(), DEFAULT_SNAPSHOT_BYTES);
166        assert_eq!(ceilings.max_index_cache_bytes(), DEFAULT_INDEX_CACHE_BYTES);
167        assert_eq!(
168            ceilings.max_index_cache_file_bytes(),
169            DEFAULT_INDEX_CACHE_BYTES + INDEX_CACHE_FILE_OVERHEAD_BYTES
170        );
171        assert_eq!(ceilings.max_wal_replay_bytes(), DEFAULT_WAL_REPLAY_BYTES);
172        assert_eq!(
173            ceilings.max_diskann_file_bytes(),
174            DEFAULT_DISKANN_FILE_BYTES
175        );
176    }
177
178    #[test]
179    fn zero_ceilings_are_rejected() {
180        assert!(StorageCeilings::new()
181            .try_with_max_snapshot_bytes(0)
182            .is_err());
183        assert!(StorageCeilings::new()
184            .try_with_max_index_cache_bytes(0)
185            .is_err());
186        assert!(StorageCeilings::new()
187            .try_with_max_wal_replay_bytes(0)
188            .is_err());
189        assert!(StorageCeilings::new()
190            .try_with_max_diskann_file_bytes(0)
191            .is_err());
192    }
193}