Skip to main content

alopex_core/kv/
search.rs

1//! Bounded wildcard and regular-expression search over opaque KV key bytes.
2
3use regex::bytes::Regex;
4use serde::{Deserialize, Serialize};
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::Arc;
7
8use crate::error::{Error, Result};
9use crate::types::{Key, Value};
10
11/// Maximum accepted pattern size.
12pub const MAX_KEY_PATTERN_BYTES: usize = 4 * 1024;
13/// Maximum number of entries returned by one page.
14pub const MAX_KEY_SEARCH_LIMIT: usize = 10_000;
15/// Maximum number of candidate keys inspected by one page.
16pub const MAX_KEY_SEARCH_SCAN_BUDGET: usize = 1_000_000;
17/// Default response payload budget before transport serialization overhead.
18pub const DEFAULT_KEY_SEARCH_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
19/// Maximum configurable response payload budget.
20pub const MAX_KEY_SEARCH_RESPONSE_BYTES: usize = 100 * 1024 * 1024;
21
22/// Cooperative cancellation shared with a running bounded search.
23#[derive(Debug, Clone, Default)]
24pub struct KeySearchCancellation(Arc<AtomicBool>);
25
26impl KeySearchCancellation {
27    /// Request cancellation. Repeated calls are harmless.
28    pub fn cancel(&self) {
29        self.0.store(true, Ordering::Release);
30    }
31
32    /// Return whether cancellation has been requested.
33    pub fn is_cancelled(&self) -> bool {
34        self.0.load(Ordering::Acquire)
35    }
36}
37
38/// Explicit matching mode for raw KV keys.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(tag = "mode", rename_all = "snake_case")]
41pub enum KeyPattern {
42    /// Byte glob where `*` matches zero or more bytes, `?` matches one byte,
43    /// and `\` escapes the following byte.
44    Glob {
45        /// Raw pattern bytes.
46        pattern: Vec<u8>,
47    },
48    /// Rust byte-regex syntax. The expression is applied directly to key bytes.
49    Regex {
50        /// Rust byte-regex source text.
51        pattern: String,
52    },
53}
54
55impl KeyPattern {
56    /// Construct an explicit byte-glob pattern.
57    pub fn glob(pattern: impl AsRef<[u8]>) -> Self {
58        Self::Glob {
59            pattern: pattern.as_ref().to_vec(),
60        }
61    }
62
63    /// Construct an explicit byte-regex pattern.
64    pub fn regex(pattern: impl Into<String>) -> Self {
65        Self::Regex {
66            pattern: pattern.into(),
67        }
68    }
69}
70
71/// One bounded, deterministic search-page request.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct KeySearchRequest {
74    /// Explicit glob or regex pattern.
75    pub pattern: KeyPattern,
76    /// Exclusive bytewise cursor from a previous page.
77    pub cursor: Option<Key>,
78    /// Maximum returned entries.
79    pub limit: usize,
80    /// Maximum candidate keys inspected after `cursor`.
81    pub scan_budget: usize,
82    /// Maximum combined key and value bytes inspected or returned by one page.
83    #[serde(default = "default_response_bytes")]
84    pub max_bytes: usize,
85}
86
87impl KeySearchRequest {
88    /// Construct a first-page request.
89    pub fn new(pattern: KeyPattern, limit: usize, scan_budget: usize) -> Self {
90        Self {
91            pattern,
92            cursor: None,
93            limit,
94            scan_budget,
95            max_bytes: DEFAULT_KEY_SEARCH_RESPONSE_BYTES,
96        }
97    }
98
99    /// Continue strictly after a prior response cursor.
100    pub fn after(mut self, cursor: Key) -> Self {
101        self.cursor = Some(cursor);
102        self
103    }
104
105    /// Override the response payload budget.
106    pub fn with_max_bytes(mut self, max_bytes: usize) -> Self {
107        self.max_bytes = max_bytes;
108        self
109    }
110}
111
112/// One matching key/value entry.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct KeySearchEntry {
115    /// Matching raw key bytes.
116    pub key: Key,
117    /// Value stored under the key.
118    pub value: Value,
119}
120
121/// A bounded search page in ascending raw-byte key order.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct KeySearchPage {
124    /// Matching entries in ascending raw-byte key order.
125    pub entries: Vec<KeySearchEntry>,
126    /// The last returned key when the page filled its limit.
127    pub next_cursor: Option<Key>,
128    /// Candidate keys inspected after the cursor.
129    pub scanned: usize,
130}
131
132pub(crate) struct PreparedKeySearch {
133    matcher: Regex,
134    prefix: Key,
135}
136
137impl PreparedKeySearch {
138    pub(crate) fn new(request: &KeySearchRequest) -> Result<Self> {
139        validate_request(request)?;
140        let (source, prefix) = match &request.pattern {
141            KeyPattern::Glob { pattern } => (glob_regex(pattern)?, glob_prefix(pattern)?),
142            // Regex syntax is rich enough that a hand-written literal-prefix
143            // extractor can introduce false negatives. Keep regex searches
144            // on the bounded full-keyspace path.
145            KeyPattern::Regex { pattern } => (pattern.clone(), Vec::new()),
146        };
147        let matcher = Regex::new(&source).map_err(|error| Error::InvalidParameter {
148            param: "pattern".into(),
149            reason: error.to_string(),
150        })?;
151        Ok(Self { matcher, prefix })
152    }
153
154    pub(crate) fn prefix(&self) -> &[u8] {
155        &self.prefix
156    }
157
158    pub(crate) fn collect(
159        &self,
160        mut next: impl FnMut() -> Result<Option<(Key, Option<Value>)>>,
161        request: &KeySearchRequest,
162        cancellation: &KeySearchCancellation,
163    ) -> Result<KeySearchPage> {
164        let mut entries = Vec::with_capacity(request.limit);
165        let mut scanned = 0usize;
166        let mut scanned_bytes = 0usize;
167        let mut response_bytes = 0usize;
168        loop {
169            if cancellation.is_cancelled() {
170                return Err(Error::SearchCancelled);
171            }
172            if scanned == request.scan_budget {
173                return Err(Error::SearchBudgetExceeded {
174                    limit: request.scan_budget,
175                });
176            }
177            let Some((key, value)) = next()? else {
178                break;
179            };
180            if !self.prefix.is_empty() && !key.starts_with(&self.prefix) {
181                break;
182            }
183            scanned += 1;
184            scanned_bytes = scanned_bytes
185                .saturating_add(key.len())
186                .saturating_add(value.as_ref().map_or(0, Vec::len));
187            if scanned_bytes > request.max_bytes {
188                return Err(Error::SearchResponseTooLarge {
189                    limit: request.max_bytes,
190                    requested: scanned_bytes,
191                });
192            }
193            if let Some(value) = value.filter(|_| self.matcher.is_match(&key)) {
194                let cursor_bytes = if entries.len() + 1 == request.limit {
195                    key.len()
196                } else {
197                    0
198                };
199                let requested = response_bytes
200                    .saturating_add(key.len())
201                    .saturating_add(value.len())
202                    .saturating_add(cursor_bytes);
203                if requested > request.max_bytes {
204                    return Err(Error::SearchResponseTooLarge {
205                        limit: request.max_bytes,
206                        requested,
207                    });
208                }
209                response_bytes = requested;
210                entries.push(KeySearchEntry { key, value });
211                if entries.len() == request.limit {
212                    break;
213                }
214            }
215        }
216        let next_cursor = (entries.len() == request.limit)
217            .then(|| entries.last().expect("non-empty full page").key.clone());
218        Ok(KeySearchPage {
219            entries,
220            next_cursor,
221            scanned,
222        })
223    }
224}
225
226fn validate_request(request: &KeySearchRequest) -> Result<()> {
227    let pattern_len = match &request.pattern {
228        KeyPattern::Glob { pattern } => pattern.len(),
229        KeyPattern::Regex { pattern } => pattern.len(),
230    };
231    if pattern_len > MAX_KEY_PATTERN_BYTES {
232        return invalid(
233            "pattern",
234            format!("must be at most {MAX_KEY_PATTERN_BYTES} bytes"),
235        );
236    }
237    if !(1..=MAX_KEY_SEARCH_LIMIT).contains(&request.limit) {
238        return invalid(
239            "limit",
240            format!("must be between 1 and {MAX_KEY_SEARCH_LIMIT}"),
241        );
242    }
243    if !(1..=MAX_KEY_SEARCH_SCAN_BUDGET).contains(&request.scan_budget) {
244        return invalid(
245            "scan_budget",
246            format!("must be between 1 and {MAX_KEY_SEARCH_SCAN_BUDGET}"),
247        );
248    }
249    if !(1..=MAX_KEY_SEARCH_RESPONSE_BYTES).contains(&request.max_bytes) {
250        return invalid(
251            "max_bytes",
252            format!("must be between 1 and {MAX_KEY_SEARCH_RESPONSE_BYTES}"),
253        );
254    }
255    Ok(())
256}
257
258fn default_response_bytes() -> usize {
259    DEFAULT_KEY_SEARCH_RESPONSE_BYTES
260}
261
262fn invalid<T>(param: &str, reason: String) -> Result<T> {
263    Err(Error::InvalidParameter {
264        param: param.into(),
265        reason,
266    })
267}
268
269fn glob_regex(pattern: &[u8]) -> Result<String> {
270    let mut source = String::from("^(?-u:");
271    let mut escaped = false;
272    for &byte in pattern {
273        if escaped {
274            push_byte(&mut source, byte);
275            escaped = false;
276        } else {
277            match byte {
278                b'\\' => escaped = true,
279                b'*' => source.push_str("(?s:.*)"),
280                b'?' => source.push_str("(?s:.)"),
281                literal => push_byte(&mut source, literal),
282            }
283        }
284    }
285    if escaped {
286        return invalid("pattern", "glob ends with an escape byte".into());
287    }
288    source.push_str(")$");
289    Ok(source)
290}
291
292fn glob_prefix(pattern: &[u8]) -> Result<Key> {
293    let mut prefix = Vec::new();
294    let mut escaped = false;
295    for &byte in pattern {
296        if escaped {
297            prefix.push(byte);
298            escaped = false;
299        } else {
300            match byte {
301                b'\\' => escaped = true,
302                b'*' | b'?' => break,
303                literal => prefix.push(literal),
304            }
305        }
306    }
307    if escaped {
308        return invalid("pattern", "glob ends with an escape byte".into());
309    }
310    Ok(prefix)
311}
312
313fn push_byte(output: &mut String, byte: u8) {
314    use std::fmt::Write;
315    write!(output, "\\x{byte:02X}").expect("writing to String cannot fail");
316}