Skip to main content

a3s_vec/collection/
resource.rs

1//! Deterministic collection-local resource admission and accounting.
2
3use crate::doc::{Doc, DocumentMap};
4use crate::error::{Error, Result};
5use crate::index::IndexRegistry;
6use crate::schema::CollectionSchema;
7use serde::{Deserialize, Deserializer, Serialize};
8use std::collections::BTreeSet;
9use std::sync::Arc;
10
11/// Collection-local logical resource budgets.
12///
13/// These limits bound published authoritative/derived logical payloads and the
14/// explicit candidate/batch admission units. `max_accounted_bytes` is
15/// deterministic engine accounting, not a promise about allocator overhead,
16/// temporary construction memory, or process RSS.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
18pub struct CollectionResourceLimits {
19    #[serde(rename = "max_documents")]
20    documents: Option<u64>,
21    #[serde(rename = "max_accounted_bytes")]
22    accounted_bytes: Option<u64>,
23    #[serde(rename = "max_query_candidates")]
24    query_candidates: Option<u64>,
25    #[serde(rename = "max_write_batch_documents")]
26    write_batch_documents: Option<u64>,
27}
28
29#[derive(Deserialize, Default)]
30#[serde(default, deny_unknown_fields)]
31struct CollectionResourceLimitsWire {
32    #[serde(rename = "max_documents")]
33    documents: Option<u64>,
34    #[serde(rename = "max_accounted_bytes")]
35    accounted_bytes: Option<u64>,
36    #[serde(rename = "max_query_candidates")]
37    query_candidates: Option<u64>,
38    #[serde(rename = "max_write_batch_documents")]
39    write_batch_documents: Option<u64>,
40}
41
42impl<'de> Deserialize<'de> for CollectionResourceLimits {
43    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
44    where
45        D: Deserializer<'de>,
46    {
47        let wire = CollectionResourceLimitsWire::deserialize(deserializer)?;
48        let mut limits = Self::new();
49        if let Some(limit) = wire.documents {
50            limits = limits
51                .try_with_max_documents(limit)
52                .map_err(serde::de::Error::custom)?;
53        }
54        if let Some(limit) = wire.accounted_bytes {
55            limits = limits
56                .try_with_max_accounted_bytes(limit)
57                .map_err(serde::de::Error::custom)?;
58        }
59        if let Some(limit) = wire.query_candidates {
60            limits = limits
61                .try_with_max_query_candidates(limit)
62                .map_err(serde::de::Error::custom)?;
63        }
64        if let Some(limit) = wire.write_batch_documents {
65            limits = limits
66                .try_with_max_write_batch_documents(limit)
67                .map_err(serde::de::Error::custom)?;
68        }
69        Ok(limits)
70    }
71}
72
73impl CollectionResourceLimits {
74    /// Creates an unbounded policy. Callers opt into each limit explicitly.
75    pub fn new() -> Self {
76        Self::default()
77    }
78
79    /// Limits the document count of every published generation.
80    pub fn try_with_max_documents(mut self, limit: u64) -> Result<Self> {
81        self.documents = Some(positive_limit(limit, "max_documents")?);
82        Ok(self)
83    }
84
85    /// Limits authoritative serialized bytes plus derived payload estimates.
86    pub fn try_with_max_accounted_bytes(mut self, limit: u64) -> Result<Self> {
87        self.accounted_bytes = Some(positive_limit(limit, "max_accounted_bytes")?);
88        Ok(self)
89    }
90
91    /// Limits planned exact/refinement candidates for one query operation.
92    pub fn try_with_max_query_candidates(mut self, limit: u64) -> Result<Self> {
93        self.query_candidates = Some(positive_limit(limit, "max_query_candidates")?);
94        Ok(self)
95    }
96
97    /// Limits documents targeted by one explicit or filter-derived write batch.
98    pub fn try_with_max_write_batch_documents(mut self, limit: u64) -> Result<Self> {
99        self.write_batch_documents = Some(positive_limit(limit, "max_write_batch_documents")?);
100        Ok(self)
101    }
102
103    /// Returns the retained-document limit, or `None` when unbounded.
104    pub fn max_documents(self) -> Option<u64> {
105        self.documents
106    }
107
108    /// Returns the logical accounted-byte limit, or `None` when unbounded.
109    pub fn max_accounted_bytes(self) -> Option<u64> {
110        self.accounted_bytes
111    }
112
113    /// Returns the per-query candidate limit, or `None` when unbounded.
114    pub fn max_query_candidates(self) -> Option<u64> {
115        self.query_candidates
116    }
117
118    /// Returns the per-write document limit, or `None` when unbounded.
119    pub fn max_write_batch_documents(self) -> Option<u64> {
120        self.write_batch_documents
121    }
122
123    pub(super) fn enforce_write_batch(self, documents: usize) -> Result<()> {
124        let documents = u64::try_from(documents)
125            .map_err(|_| Error::resource_exhausted("write batch exceeds u64 documents"))?;
126        if self
127            .write_batch_documents
128            .is_some_and(|limit| documents > limit)
129        {
130            return Err(Error::resource_exhausted(format!(
131                "write batch document count {documents} exceeds configured limit {}",
132                self.write_batch_documents.unwrap_or(u64::MAX)
133            )));
134        }
135        Ok(())
136    }
137
138    pub(super) fn enforce_query_candidates(self, candidates: u64) -> Result<()> {
139        if self
140            .query_candidates
141            .is_some_and(|limit| candidates > limit)
142        {
143            return Err(Error::resource_exhausted(format!(
144                "query candidate count {candidates} exceeds configured limit {}",
145                self.query_candidates.unwrap_or(u64::MAX)
146            )));
147        }
148        Ok(())
149    }
150
151    pub(super) fn enforce_state(
152        self,
153        schema: &CollectionSchema,
154        docs: &DocumentMap,
155        indexes: &IndexRegistry,
156    ) -> Result<ResourceUsage> {
157        let document_count = u64::try_from(docs.len())
158            .map_err(|_| Error::resource_exhausted("collection exceeds u64 documents"))?;
159        let usage = ResourceUsage::measure(schema, docs, indexes)?;
160        self.admit(document_count, usage)
161    }
162
163    pub(super) fn admit(self, document_count: u64, usage: ResourceUsage) -> Result<ResourceUsage> {
164        if self.documents.is_some_and(|limit| document_count > limit) {
165            return Err(Error::resource_exhausted(format!(
166                "collection document count {document_count} exceeds configured limit {}",
167                self.documents.unwrap_or(u64::MAX)
168            )));
169        }
170        if self
171            .accounted_bytes
172            .is_some_and(|limit| usage.total > limit)
173        {
174            return Err(Error::resource_exhausted(format!(
175                "collection accounted bytes {} exceeds configured limit {}",
176                usage.total,
177                self.accounted_bytes.unwrap_or(u64::MAX)
178            )));
179        }
180        Ok(usage)
181    }
182}
183
184fn positive_limit(limit: u64, name: &str) -> Result<u64> {
185    if limit == 0 {
186        Err(Error::invalid_argument(format!(
187            "resource limit '{name}' must be positive"
188        )))
189    } else {
190        Ok(limit)
191    }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub(super) struct ResourceUsage {
196    pub documents: u64,
197    pub indexes: u64,
198    pub total: u64,
199}
200
201impl ResourceUsage {
202    pub(super) fn measure(
203        _schema: &CollectionSchema,
204        docs: &DocumentMap,
205        indexes: &IndexRegistry,
206    ) -> Result<Self> {
207        Ok(Self::from_parts(
208            document_map_bytes(docs)?,
209            indexes.accounted_payload_bytes(),
210        ))
211    }
212
213    /// Updates document accounting from the changed primary keys only.
214    ///
215    /// `bincode` encodes an `OrdMap` as one length prefix plus each key and
216    /// value. The prefix width does not grow with the entry count, so a
217    /// generation can add and remove entry sizes without walking documents
218    /// that this batch did not touch. A drift or overflow falls back to a
219    /// full measurement.
220    pub(super) fn after_documents(
221        &self,
222        previous_docs: &DocumentMap,
223        next_docs: &DocumentMap,
224        changed_ids: &BTreeSet<String>,
225        indexes: &IndexRegistry,
226    ) -> Result<Self> {
227        let mut documents = self.documents;
228        for id in changed_ids {
229            let before = document_entry_bytes(id, previous_docs.get(id))?;
230            let after = document_entry_bytes(id, next_docs.get(id))?;
231            if before > documents {
232                return Ok(Self::from_parts(
233                    document_map_bytes(next_docs)?,
234                    indexes.accounted_payload_bytes(),
235                ));
236            }
237            documents -= before;
238            documents = match documents.checked_add(after) {
239                Some(documents) => documents,
240                None => {
241                    return Ok(Self::from_parts(
242                        document_map_bytes(next_docs)?,
243                        indexes.accounted_payload_bytes(),
244                    ));
245                }
246            };
247        }
248        Ok(Self::from_parts(
249            documents,
250            indexes.accounted_payload_bytes(),
251        ))
252    }
253
254    fn from_parts(documents: u64, indexes: u64) -> Self {
255        Self {
256            documents,
257            indexes,
258            total: documents.saturating_add(indexes),
259        }
260    }
261}
262
263fn document_map_bytes(docs: &DocumentMap) -> Result<u64> {
264    bincode::serialized_size(docs)
265        .map_err(|error| Error::internal(format!("account document bytes: {error}")))
266}
267
268fn document_entry_bytes(id: &str, doc: Option<&Arc<Doc>>) -> Result<u64> {
269    let Some(doc) = doc else {
270        return Ok(0);
271    };
272    let key = bincode::serialized_size(id)
273        .map_err(|error| Error::internal(format!("account document key: {error}")))?;
274    let value = bincode::serialized_size(doc)
275        .map_err(|error| Error::internal(format!("account document value: {error}")))?;
276    Ok(key.saturating_add(value))
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn defaults_are_explicitly_unbounded() {
285        let limits = CollectionResourceLimits::default();
286        assert_eq!(limits.max_documents(), None);
287        assert_eq!(limits.max_accounted_bytes(), None);
288        assert_eq!(limits.max_query_candidates(), None);
289        assert_eq!(limits.max_write_batch_documents(), None);
290    }
291
292    #[test]
293    fn deserialization_preserves_the_typed_invariants() {
294        let limits: CollectionResourceLimits =
295            serde_json::from_str(r#"{"max_documents":10,"max_query_candidates":20}"#)
296                .expect("partial policies must default omitted limits to unbounded");
297        assert_eq!(limits.max_documents(), Some(10));
298        assert_eq!(limits.max_accounted_bytes(), None);
299        assert_eq!(limits.max_query_candidates(), Some(20));
300        assert!(
301            serde_json::from_str::<CollectionResourceLimits>(r#"{"max_documents":0}"#).is_err()
302        );
303        assert!(
304            serde_json::from_str::<CollectionResourceLimits>(r#"{"max_documentz":10}"#).is_err()
305        );
306    }
307
308    #[test]
309    fn public_policy_is_send_and_sync() {
310        fn assert_send_sync<T: Send + Sync>() {}
311        assert_send_sync::<CollectionResourceLimits>();
312    }
313
314    #[test]
315    fn document_map_bincode_size_is_the_header_plus_entries() {
316        use crate::doc::{Doc, DocumentMap};
317        use std::sync::Arc;
318
319        let mut docs = DocumentMap::new();
320        let mut running = bincode::serialized_size(&docs).expect("empty map size");
321        for index in 0..6 {
322            let mut doc = Doc::with_pk(format!("doc-{index}")).expect("primary key");
323            let coordinate = f32::from(u8::try_from(index).expect("fixture index"));
324            doc.add_vector_f32("embedding", &[coordinate, -1.0, 0.25])
325                .expect("vector");
326            if index % 2 == 0 {
327                doc.add_string("body", "kept").expect("field");
328            }
329            let id = doc.get_pk().expect("pk").to_string();
330            let stored = Arc::new(doc);
331            running += super::document_entry_bytes(&id, Some(&stored)).expect("entry");
332            docs.insert(id, stored);
333            assert_eq!(
334                bincode::serialized_size(&docs).expect("map size"),
335                running,
336                "insert {index}"
337            );
338        }
339        let removed_id = "doc-2".to_string();
340        let removed = docs.get(&removed_id).expect("present").clone();
341        running -= super::document_entry_bytes(&removed_id, Some(&removed)).expect("removed entry");
342        docs.remove(&removed_id);
343        assert_eq!(bincode::serialized_size(&docs).expect("map size"), running);
344
345        let replaced_id = "doc-4".to_string();
346        let previous = docs.get(&replaced_id).expect("present").clone();
347        let mut replacement = Doc::with_pk(replaced_id.clone()).expect("primary key");
348        replacement
349            .add_vector_f32("embedding", &[9.0, 8.0, 7.0])
350            .expect("vector");
351        let stored = Arc::new(replacement);
352        running -= super::document_entry_bytes(&replaced_id, Some(&previous)).expect("old entry");
353        running += super::document_entry_bytes(&replaced_id, Some(&stored)).expect("new entry");
354        docs.insert(replaced_id, stored);
355        assert_eq!(bincode::serialized_size(&docs).expect("map size"), running);
356    }
357}