Skip to main content

loonfs_api/v0/
search.rs

1//! Content search (grep) request and response shapes: the `query/v0`
2//! plane's first operation (API spec, "Content search").
3
4use crate::{AbsolutePath, ChangeSeq, CheckpointId, InodeId, NamespaceId, RevisionNo, RunNo};
5use serde::{Deserialize, Serialize};
6use xxhash_rust::xxh64::xxh64;
7
8/// One content-search request.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct GrepRequest {
11    /// The pattern, in the Rust `regex` crate's dialect (no backreferences
12    /// or lookaround). Its UTF-8 encoding must be at most 1024 bytes.
13    /// Patterns that require no literal bytes are rejected with
14    /// `query_unindexable` unless `allow_scan` is set.
15    pub pattern: String,
16    /// Match case-insensitively. Verification is exact; the index remains
17    /// consulted through its case-folded grams.
18    pub case_insensitive: bool,
19    /// Restrict matches to files under this complete absolute path, resolved
20    /// to a directory inode before candidates are filtered.
21    pub path_prefix: Option<AbsolutePath>,
22    /// Resume cursor from a previous page. The cursor resumes strictly
23    /// after the last candidate the issuing page finished scanning and is
24    /// bound to that page's request; each page is evaluated against the
25    /// namespace head at page time.
26    pub cursor: Option<String>,
27    /// When the unindexed tail exceeds the scan budget or crosses an
28    /// undelete that requires an index rebuild, return indexed-only results
29    /// (reported via `tail_scanned: false`) instead of failing with
30    /// `index_lagging`.
31    pub allow_stale: bool,
32    /// Permit a capped exhaustive scan when the pattern yields no required
33    /// grams. Refused beyond the server's scan budget.
34    pub allow_scan: bool,
35}
36
37impl GrepRequest {
38    /// Fingerprint of the fields that select results, binding cursors to
39    /// the request that issued them. Not a durable format: cursors are
40    /// opaque and short-lived, so this may change between builds.
41    pub fn fingerprint(&self) -> u64 {
42        let mut seed = xxh64(self.pattern.as_bytes(), 0);
43        seed = xxh64(
44            self.path_prefix
45                .as_ref()
46                .map_or("", AbsolutePath::as_str)
47                .as_bytes(),
48            seed,
49        );
50        let flags = [
51            u8::from(self.case_insensitive),
52            u8::from(self.allow_stale),
53            u8::from(self.allow_scan),
54        ];
55        xxh64(&flags, seed)
56    }
57}
58
59/// One line-oriented match.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
62pub struct GrepMatch {
63    /// The file's absolute path, derived at the snapshot.
64    pub path: AbsolutePath,
65    /// Durable identity of the matched file.
66    #[serde(with = "crate::public_inode_id")]
67    #[cfg_attr(
68        feature = "openapi",
69        schema(schema_with = crate::public_inode_id::schema)
70    )]
71    pub inode_id: InodeId,
72    /// The matched revision (the newest visible one at the snapshot).
73    pub revision_no: RevisionNo,
74    /// One-based line number of the match.
75    pub line_number: u64,
76    /// Byte offset of the match within the file.
77    pub byte_offset: u64,
78    /// The matching line, truncated to the server's line cap.
79    pub line: String,
80    /// True when `line` was truncated.
81    pub line_truncated: bool,
82}
83
84/// One content-search page.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
87pub struct GrepResponse {
88    /// Namespace searched.
89    pub namespace_id: NamespaceId,
90    /// Sequence this page was evaluated at. Pages are evaluated against
91    /// the namespace head at page time; the cursor is an ordering resume,
92    /// not a snapshot pin.
93    pub head_seq: ChangeSeq,
94    /// Commits at or below this sequence were answered from the index.
95    pub built_through_seq: ChangeSeq,
96    /// True when revisions after `built_through_seq` were scanned
97    /// exhaustively; false only when `allow_stale` skipped them.
98    pub tail_scanned: bool,
99    /// Matches in ascending `(inode_id, byte_offset)` order. A page may
100    /// return fewer matches than its limit and still carry a cursor: the
101    /// per-page verified-candidate budget bounds how much content one
102    /// request reads, whatever the plan's false-positive rate.
103    pub matches: Vec<GrepMatch>,
104    /// Present when another page follows.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub next_cursor: Option<String>,
107}
108
109/// Where a namespace's grep index is in its lifecycle.
110///
111/// Each status contains only the fields valid for that lifecycle state.
112/// `Backfilling` reports its target and current position. `Active` reports
113/// how far the index has been built. Clients should treat a namespace as
114/// searchable only when the index is `Active`.
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
117#[serde(tag = "status", rename_all = "snake_case")]
118pub enum GrepIndexLifecycle {
119    /// No index is maintained for this namespace.
120    Disabled,
121    /// The initial walk over a pinned checkpoint is running. Nothing is
122    /// searchable yet.
123    Backfilling {
124        /// Namespace sequence the pinned checkpoint captured. Reaching it
125        /// is what completes the backfill.
126        target_seq: ChangeSeq,
127        /// Inode the walk resumes strictly after. Absent before the first
128        /// page.
129        #[serde(
130            default,
131            skip_serializing_if = "Option::is_none",
132            with = "crate::public_inode_id::option"
133        )]
134        #[cfg_attr(
135            feature = "openapi",
136            schema(schema_with = crate::public_inode_id::schema)
137        )]
138        cursor_inode_id: Option<InodeId>,
139        /// Checkpoint pinning the state being walked.
140        checkpoint_id: CheckpointId,
141    },
142    /// The index follows the change feed. Commits at or below the watermark
143    /// are searchable.
144    Active {
145        /// Sequence of the commit at the index cursor.
146        built_through_seq: ChangeSeq,
147        /// Offset of the next change event within `built_through_seq`, or
148        /// zero when the whole commit is represented.
149        #[serde(default, skip_serializing_if = "is_zero")]
150        next_event_index: u32,
151    },
152}
153
154impl GrepIndexLifecycle {
155    /// Whether every commit at or below `target_seq` is represented.
156    ///
157    /// A watermark inside a commit (`next_event_index` above zero) has that
158    /// commit only partly indexed, so it counts as reached only for earlier
159    /// sequences.
160    pub fn is_built_through(&self, target_seq: ChangeSeq) -> bool {
161        match self {
162            Self::Disabled | Self::Backfilling { .. } => false,
163            Self::Active {
164                built_through_seq,
165                next_event_index,
166            } => {
167                *built_through_seq > target_seq
168                    || (*built_through_seq == target_seq && *next_event_index == 0)
169            }
170        }
171    }
172}
173
174fn is_zero(value: &u32) -> bool {
175    *value == 0
176}
177
178/// The namespace's grep-index lifecycle and its cheap bookkeeping (admin
179/// plane).
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
182pub struct GrepIndex {
183    /// Namespace the status describes.
184    pub namespace_id: NamespaceId,
185    /// Where the index is in its lifecycle.
186    #[serde(flatten)]
187    pub lifecycle: GrepIndexLifecycle,
188    /// Run number the index allocates next.
189    pub next_run_no: RunNo,
190    /// True while a partitioned segment reorganization is in progress.
191    pub reorganize_pending: bool,
192}
193
194/// One explicit grep-index garbage-collection pass (admin plane).
195#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
196#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
197#[serde(deny_unknown_fields)]
198pub struct GrepGcRequest {
199    /// Reads this pass may spend before returning with a `next_cursor`.
200    /// Omit to take the same per-pass default the runtime's own collection
201    /// takes.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub max_objects: Option<u64>,
204    /// Opaque resume token returned as `next_cursor` by an earlier pass
205    /// against the same namespace.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub cursor: Option<String>,
208}
209
210/// Result of one explicit grep-index garbage-collection pass (admin plane).
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
213pub struct GrepGcResponse {
214    /// Namespace whose grep-owned keyspace was inspected.
215    pub namespace_id: NamespaceId,
216    /// Unreferenced grep segments deleted after the grace window.
217    pub deleted_segments: u64,
218    /// Other unreferenced grep objects deleted after the grace window.
219    pub deleted_other_objects: u64,
220    /// Whether an absent or tombstoned namespace had extension state reaped.
221    pub namespace_reaped: bool,
222    /// Young or concurrently revived candidates retained by the pass.
223    pub retained_candidates: u64,
224    /// Whether unreadable namespace or grep state forced conservative retention.
225    pub namespace_degraded: bool,
226    /// Present when the budget stopped the pass with keys left to examine.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub next_cursor: Option<String>,
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn grep_paths_keep_the_plain_string_wire_shape() {
237        let found = GrepMatch {
238            path: AbsolutePath::parse("/docs/a.txt").expect("match path"),
239            inode_id: InodeId(2),
240            revision_no: RevisionNo(3),
241            line_number: 4,
242            byte_offset: 5,
243            line: "needle".to_owned(),
244            line_truncated: false,
245        };
246        assert_eq!(
247            serde_json::to_value(found).expect("serialize grep match"),
248            serde_json::json!({
249                "path": "/docs/a.txt",
250                "inode_id": "ino_2",
251                "revision_no": 3,
252                "line_number": 4,
253                "byte_offset": 5,
254                "line": "needle",
255                "line_truncated": false
256            })
257        );
258    }
259
260    #[test]
261    fn lifecycle_statuses_never_share_a_sequence_field() {
262        let backfilling = GrepIndexLifecycle::Backfilling {
263            target_seq: ChangeSeq(9),
264            cursor_inode_id: Some(InodeId(4)),
265            checkpoint_id: CheckpointId::parse("chk_00000000000000000000000000000009")
266                .expect("checkpoint id"),
267        };
268        assert_eq!(
269            serde_json::to_value(&backfilling).expect("serialize backfilling"),
270            serde_json::json!({
271                "status": "backfilling",
272                "target_seq": 9,
273                "cursor_inode_id": "ino_4",
274                "checkpoint_id": "chk_00000000000000000000000000000009"
275            }),
276            "a backfill reports its target and its walk, never a watermark"
277        );
278
279        assert_eq!(
280            serde_json::to_value(GrepIndexLifecycle::Active {
281                built_through_seq: ChangeSeq(9),
282                next_event_index: 0,
283            })
284            .expect("serialize active"),
285            serde_json::json!({"status": "active", "built_through_seq": 9}),
286            "an active index reports its watermark and no target"
287        );
288
289        assert_eq!(
290            serde_json::to_value(GrepIndexLifecycle::Disabled).expect("serialize disabled"),
291            serde_json::json!({"status": "disabled"})
292        );
293    }
294
295    #[test]
296    fn only_an_active_index_has_built_through_a_sequence() {
297        let backfilling = GrepIndexLifecycle::Backfilling {
298            target_seq: ChangeSeq(9),
299            cursor_inode_id: None,
300            checkpoint_id: CheckpointId::parse("chk_00000000000000000000000000000009")
301                .expect("checkpoint id"),
302        };
303        assert!(
304            !backfilling.is_built_through(ChangeSeq(0)),
305            "a backfill has indexed nothing until it turns active"
306        );
307        assert!(!GrepIndexLifecycle::Disabled.is_built_through(ChangeSeq(0)));
308
309        let active = |built_through_seq, next_event_index| GrepIndexLifecycle::Active {
310            built_through_seq,
311            next_event_index,
312        };
313        assert!(active(ChangeSeq(9), 0).is_built_through(ChangeSeq(9)));
314        assert!(active(ChangeSeq(9), 0).is_built_through(ChangeSeq(8)));
315        assert!(!active(ChangeSeq(9), 0).is_built_through(ChangeSeq(10)));
316        // A watermark inside a commit leaves the rest of that commit
317        // unindexed, so only earlier sequences count as reached.
318        assert!(!active(ChangeSeq(9), 3).is_built_through(ChangeSeq(9)));
319        assert!(active(ChangeSeq(9), 3).is_built_through(ChangeSeq(8)));
320    }
321
322    #[test]
323    fn grep_index_status_flattens_its_lifecycle() {
324        assert_eq!(
325            serde_json::to_value(GrepIndex {
326                namespace_id: NamespaceId::parse("demo").expect("namespace id"),
327                lifecycle: GrepIndexLifecycle::Active {
328                    built_through_seq: ChangeSeq(12),
329                    next_event_index: 0,
330                },
331                next_run_no: RunNo(3),
332                reorganize_pending: false,
333            })
334            .expect("serialize active status"),
335            serde_json::json!({
336                "namespace_id": "demo",
337                "status": "active",
338                "built_through_seq": 12,
339                "next_run_no": 3,
340                "reorganize_pending": false
341            })
342        );
343
344        assert_eq!(
345            serde_json::to_value(GrepIndex {
346                namespace_id: NamespaceId::parse("demo").expect("namespace id"),
347                lifecycle: GrepIndexLifecycle::Backfilling {
348                    target_seq: ChangeSeq(12),
349                    cursor_inode_id: Some(InodeId(4)),
350                    checkpoint_id: CheckpointId::parse("chk_00000000000000000000000000000009")
351                        .expect("checkpoint id"),
352                },
353                next_run_no: RunNo(1),
354                reorganize_pending: false,
355            })
356            .expect("serialize backfilling status"),
357            serde_json::json!({
358                "namespace_id": "demo",
359                "status": "backfilling",
360                "target_seq": 12,
361                "cursor_inode_id": "ino_4",
362                "checkpoint_id": "chk_00000000000000000000000000000009",
363                "next_run_no": 1,
364                "reorganize_pending": false
365            })
366        );
367    }
368
369    #[test]
370    fn grep_gc_request_bodies_reject_unknown_fields() {
371        serde_json::from_value::<GrepGcRequest>(serde_json::json!({"max_objects": 8}))
372            .expect("the same collection body without a typo decodes");
373        assert!(
374            serde_json::from_value::<GrepGcRequest>(serde_json::json!({"maxObjects": 8})).is_err()
375        );
376    }
377}