Skip to main content

loonfs_api/v0/
search.rs

1//! Content search requests and responses for the v0 HTTP API.
2
3use crate::{AbsolutePath, ChangeSeq, CheckpointId, InodeId, NamespaceId, RevisionNo, RunNo};
4use serde::{Deserialize, Serialize};
5use xxhash_rust::xxh64::xxh64;
6
7/// One content-search request.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct GrepRequest {
10    /// The Rust `regex` pattern, without backreferences or lookaround, limited to
11    /// 1,024 UTF-8 bytes and requiring `allow_scan` when it has no required literal
12    /// bytes.
13    pub pattern: String,
14    /// Whether matching ignores case.
15    pub case_insensitive: bool,
16    /// The absolute directory path that limits matching to its descendants.
17    pub path_prefix: Option<AbsolutePath>,
18    /// The cursor from the previous page, bound to that request and evaluated against
19    /// the current namespace head.
20    pub cursor: Option<String>,
21    /// Whether index lag returns indexed-only results with `tail_scanned: false`
22    /// instead of `index_lagging`.
23    pub allow_stale: bool,
24    /// Whether patterns without required literal bytes may use a scan capped by the
25    /// server's budget.
26    pub allow_scan: bool,
27}
28
29impl GrepRequest {
30    /// Fingerprint of the fields that select results, binding cursors to
31    /// the request that issued them. Not a durable format: cursors are
32    /// opaque and short-lived, so this may change between builds.
33    pub fn fingerprint(&self) -> u64 {
34        let mut seed = xxh64(self.pattern.as_bytes(), 0);
35        seed = xxh64(
36            self.path_prefix
37                .as_ref()
38                .map_or("", AbsolutePath::as_str)
39                .as_bytes(),
40            seed,
41        );
42        let flags = [
43            u8::from(self.case_insensitive),
44            u8::from(self.allow_stale),
45            u8::from(self.allow_scan),
46        ];
47        xxh64(&flags, seed)
48    }
49}
50
51/// One line-oriented match.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
54pub struct GrepMatch {
55    /// The file's absolute path, derived at the snapshot.
56    pub path: AbsolutePath,
57    /// Durable identity of the matched file.
58    #[serde(with = "crate::public_inode_id")]
59    pub inode_id: InodeId,
60    /// The matched revision (the newest visible one at the snapshot).
61    pub revision_no: RevisionNo,
62    /// One-based line number of the match.
63    pub line_number: u64,
64    /// Byte offset of the match within the file.
65    pub byte_offset: u64,
66    /// The matching line, truncated to the server's line cap.
67    pub line: String,
68    /// True when `line` was truncated.
69    pub line_truncated: bool,
70}
71
72/// One content-search page.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
75pub struct GrepResponse {
76    /// Namespace searched.
77    pub namespace_id: NamespaceId,
78    /// The namespace head sequence used to evaluate this page.
79    pub head_seq: ChangeSeq,
80    /// Commits at or below this sequence were answered from the index.
81    pub built_through_seq: ChangeSeq,
82    /// Whether revisions after `built_through_seq` were scanned exhaustively.
83    pub tail_scanned: bool,
84    /// The matches in ascending `(inode_id, byte_offset)` order.
85    pub matches: Vec<GrepMatch>,
86    /// Present when another page follows.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    #[cfg_attr(feature = "openapi", schema(nullable = false))]
89    pub next_cursor: Option<String>,
90}
91
92/// The grep index lifecycle for a namespace.
93///
94/// A namespace is searchable only when the lifecycle is `Active`.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
97#[serde(tag = "status", rename_all = "snake_case")]
98pub enum GrepIndexLifecycle {
99    /// No index is maintained for this namespace.
100    Disabled,
101    /// An initial scan of a pinned checkpoint that is not yet searchable.
102    Backfilling {
103        /// The namespace sequence that completes the backfill when reached.
104        target_seq: ChangeSeq,
105        /// The inode after which the scan resumes, or `None` before the first page.
106        #[serde(
107            default,
108            skip_serializing_if = "Option::is_none",
109            with = "crate::public_inode_id::option"
110        )]
111        #[cfg_attr(feature = "openapi", schema(nullable = false))]
112        cursor_inode_id: Option<InodeId>,
113        /// Checkpoint pinning the state being walked.
114        checkpoint_id: CheckpointId,
115    },
116    /// An index following the change feed through its searchable watermark.
117    Active {
118        /// Sequence of the commit at the index cursor.
119        built_through_seq: ChangeSeq,
120        /// The next change-event offset within `built_through_seq`, or zero when the
121        /// whole commit is indexed.
122        #[serde(default, skip_serializing_if = "is_zero")]
123        next_event_index: u32,
124    },
125}
126
127impl GrepIndexLifecycle {
128    /// Whether every commit at or below `target_seq` is represented.
129    ///
130    /// A watermark inside a commit (`next_event_index` above zero) has that
131    /// commit only partly indexed, so it counts as reached only for earlier
132    /// sequences.
133    pub fn is_built_through(&self, target_seq: ChangeSeq) -> bool {
134        match self {
135            Self::Disabled | Self::Backfilling { .. } => false,
136            Self::Active {
137                built_through_seq,
138                next_event_index,
139            } => {
140                *built_through_seq > target_seq
141                    || (*built_through_seq == target_seq && *next_event_index == 0)
142            }
143        }
144    }
145}
146
147fn is_zero(value: &u32) -> bool {
148    *value == 0
149}
150
151/// The maintenance status of a namespace's grep index.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
154pub struct GrepIndex {
155    /// Namespace the status describes.
156    pub namespace_id: NamespaceId,
157    /// Where the index is in its lifecycle.
158    #[serde(flatten)]
159    pub lifecycle: GrepIndexLifecycle,
160    /// Run number the index allocates next.
161    pub next_run_no: RunNo,
162    /// True while a partitioned segment reorganization is in progress.
163    pub reorganize_pending: bool,
164}
165
166/// One explicit grep index garbage-collection pass (maintenance API group).
167#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
168#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
169#[serde(deny_unknown_fields)]
170pub struct GrepGcRequest {}
171
172/// Result of one explicit grep index garbage-collection pass (maintenance API group).
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
175pub struct GrepGcResponse {
176    /// Namespace whose grep-owned keyspace was inspected.
177    pub namespace_id: NamespaceId,
178    /// Unreferenced grep segments older than the minimum segment age.
179    pub deleted_segments: u64,
180    /// Other unreferenced grep objects deleted after the grace window.
181    pub deleted_other_objects: u64,
182    /// Whether an absent or tombstoned namespace had extension state reaped.
183    pub namespace_reaped: bool,
184    /// Referenced, young, or unrecognized candidates retained by the pass.
185    pub retained_candidates: u64,
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn grep_paths_keep_the_plain_string_wire_shape() {
194        let found = GrepMatch {
195            path: AbsolutePath::parse("/docs/a.txt").expect("match path"),
196            inode_id: InodeId(2),
197            revision_no: RevisionNo(3),
198            line_number: 4,
199            byte_offset: 5,
200            line: "needle".to_owned(),
201            line_truncated: false,
202        };
203        assert_eq!(
204            serde_json::to_value(found).expect("serialize grep match"),
205            serde_json::json!({
206                "path": "/docs/a.txt",
207                "inode_id": "ino_2",
208                "revision_no": 3,
209                "line_number": 4,
210                "byte_offset": 5,
211                "line": "needle",
212                "line_truncated": false
213            })
214        );
215    }
216
217    #[test]
218    fn lifecycle_statuses_never_share_a_sequence_field() {
219        let backfilling = GrepIndexLifecycle::Backfilling {
220            target_seq: ChangeSeq(9),
221            cursor_inode_id: Some(InodeId(4)),
222            checkpoint_id: CheckpointId::parse("pin_00000000000000000001-0000000000000009")
223                .expect("checkpoint id"),
224        };
225        assert_eq!(
226            serde_json::to_value(&backfilling).expect("serialize backfilling"),
227            serde_json::json!({
228                "status": "backfilling",
229                "target_seq": 9,
230                "cursor_inode_id": "ino_4",
231                "checkpoint_id": "pin_00000000000000000001-0000000000000009"
232            }),
233            "a backfill reports its target and its walk, never a watermark"
234        );
235
236        assert_eq!(
237            serde_json::to_value(GrepIndexLifecycle::Active {
238                built_through_seq: ChangeSeq(9),
239                next_event_index: 0,
240            })
241            .expect("serialize active"),
242            serde_json::json!({"status": "active", "built_through_seq": 9}),
243            "an active index reports its watermark and no target"
244        );
245
246        assert_eq!(
247            serde_json::to_value(GrepIndexLifecycle::Disabled).expect("serialize disabled"),
248            serde_json::json!({"status": "disabled"})
249        );
250    }
251
252    #[test]
253    fn only_an_active_index_has_built_through_a_sequence() {
254        let backfilling = GrepIndexLifecycle::Backfilling {
255            target_seq: ChangeSeq(9),
256            cursor_inode_id: None,
257            checkpoint_id: CheckpointId::parse("pin_00000000000000000001-0000000000000009")
258                .expect("checkpoint id"),
259        };
260        assert!(
261            !backfilling.is_built_through(ChangeSeq(0)),
262            "a backfill has indexed nothing until it turns active"
263        );
264        assert!(!GrepIndexLifecycle::Disabled.is_built_through(ChangeSeq(0)));
265
266        let active = |built_through_seq, next_event_index| GrepIndexLifecycle::Active {
267            built_through_seq,
268            next_event_index,
269        };
270        assert!(active(ChangeSeq(9), 0).is_built_through(ChangeSeq(9)));
271        assert!(active(ChangeSeq(9), 0).is_built_through(ChangeSeq(8)));
272        assert!(!active(ChangeSeq(9), 0).is_built_through(ChangeSeq(10)));
273        // A watermark inside a commit leaves the rest of that commit
274        // unindexed, so only earlier sequences count as reached.
275        assert!(!active(ChangeSeq(9), 3).is_built_through(ChangeSeq(9)));
276        assert!(active(ChangeSeq(9), 3).is_built_through(ChangeSeq(8)));
277    }
278
279    #[test]
280    fn grep_index_status_flattens_its_lifecycle() {
281        assert_eq!(
282            serde_json::to_value(GrepIndex {
283                namespace_id: NamespaceId::parse("demo").expect("namespace id"),
284                lifecycle: GrepIndexLifecycle::Active {
285                    built_through_seq: ChangeSeq(12),
286                    next_event_index: 0,
287                },
288                next_run_no: RunNo(3),
289                reorganize_pending: false,
290            })
291            .expect("serialize active status"),
292            serde_json::json!({
293                "namespace_id": "demo",
294                "status": "active",
295                "built_through_seq": 12,
296                "next_run_no": 3,
297                "reorganize_pending": false
298            })
299        );
300
301        assert_eq!(
302            serde_json::to_value(GrepIndex {
303                namespace_id: NamespaceId::parse("demo").expect("namespace id"),
304                lifecycle: GrepIndexLifecycle::Backfilling {
305                    target_seq: ChangeSeq(12),
306                    cursor_inode_id: Some(InodeId(4)),
307                    checkpoint_id: CheckpointId::parse("pin_00000000000000000001-0000000000000009")
308                        .expect("checkpoint id"),
309                },
310                next_run_no: RunNo(1),
311                reorganize_pending: false,
312            })
313            .expect("serialize backfilling status"),
314            serde_json::json!({
315                "namespace_id": "demo",
316                "status": "backfilling",
317                "target_seq": 12,
318                "cursor_inode_id": "ino_4",
319                "checkpoint_id": "pin_00000000000000000001-0000000000000009",
320                "next_run_no": 1,
321                "reorganize_pending": false
322            })
323        );
324    }
325
326    #[test]
327    fn grep_gc_request_bodies_reject_unknown_fields() {
328        serde_json::from_value::<GrepGcRequest>(serde_json::json!({}))
329            .expect("an empty collection request decodes");
330        assert!(
331            serde_json::from_value::<GrepGcRequest>(serde_json::json!({"max_objects": 8})).is_err()
332        );
333    }
334}