1use crate::{AbsolutePath, ChangeSeq, CheckpointId, InodeId, NamespaceId, RevisionNo};
5use serde::{Deserialize, Serialize};
6use xxhash_rust::xxh64::xxh64;
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
16#[serde(deny_unknown_fields)]
17pub struct GrepRequest {
18 pub pattern: String,
22 #[serde(default)]
25 pub case_insensitive: bool,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub path_prefix: Option<AbsolutePath>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub cursor: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub limit: Option<u32>,
39 #[serde(default)]
43 pub allow_stale: bool,
44 #[serde(default)]
47 pub allow_scan: bool,
48}
49
50impl GrepRequest {
51 pub fn fingerprint(&self) -> u64 {
55 let mut seed = xxh64(self.pattern.as_bytes(), 0);
56 seed = xxh64(
57 self.path_prefix
58 .as_ref()
59 .map(AbsolutePath::as_str)
60 .unwrap_or("")
61 .as_bytes(),
62 seed,
63 );
64 let flags = [
65 u8::from(self.case_insensitive),
66 u8::from(self.allow_stale),
67 u8::from(self.allow_scan),
68 ];
69 xxh64(&flags, seed)
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
76pub struct GrepMatch {
77 pub path: AbsolutePath,
79 #[serde(with = "crate::public_inode_id")]
81 #[cfg_attr(
82 feature = "openapi",
83 schema(schema_with = crate::public_inode_id::schema)
84 )]
85 pub inode_id: InodeId,
86 pub revision_no: RevisionNo,
88 pub line_number: u64,
90 pub byte_offset: u64,
92 pub line: String,
94 #[serde(default)]
96 pub line_truncated: bool,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
102pub struct GrepResponse {
103 pub namespace_id: NamespaceId,
105 pub head_seq: ChangeSeq,
109 pub built_through_seq: ChangeSeq,
111 pub tail_scanned: bool,
114 pub matches: Vec<GrepMatch>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub next_cursor: Option<String>,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
132#[serde(tag = "status", rename_all = "snake_case")]
133pub enum GrepIndexLifecycle {
134 Disabled,
136 Backfilling {
139 target_seq: ChangeSeq,
142 #[serde(
145 default,
146 skip_serializing_if = "Option::is_none",
147 with = "crate::public_inode_id::option"
148 )]
149 #[cfg_attr(
150 feature = "openapi",
151 schema(schema_with = crate::public_inode_id::optional_schema)
152 )]
153 cursor_inode_id: Option<InodeId>,
154 checkpoint_id: CheckpointId,
156 },
157 Active {
160 built_through_seq: ChangeSeq,
162 #[serde(default, skip_serializing_if = "is_zero")]
165 next_event_index: u32,
166 },
167}
168
169impl GrepIndexLifecycle {
170 pub fn is_built_through(&self, target_seq: ChangeSeq) -> bool {
176 match self {
177 Self::Disabled | Self::Backfilling { .. } => false,
178 Self::Active {
179 built_through_seq,
180 next_event_index,
181 } => {
182 *built_through_seq > target_seq
183 || (*built_through_seq == target_seq && *next_event_index == 0)
184 }
185 }
186 }
187}
188
189fn is_zero(value: &u32) -> bool {
190 *value == 0
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
197pub struct GrepIndexStatusResponse {
198 pub namespace_id: NamespaceId,
200 #[serde(flatten)]
202 pub lifecycle: GrepIndexLifecycle,
203 #[cfg_attr(feature = "openapi", schema(maximum = 9007199254740991_u64))]
205 pub next_run_ordinal: u64,
206 pub reorganize_pending: bool,
208}
209
210#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
212#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
213#[serde(deny_unknown_fields)]
214pub struct GrepGcRequest {
215 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub max_objects: Option<u64>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub cursor: Option<String>,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
229pub struct GrepGcResponse {
230 pub namespace_id: NamespaceId,
232 pub deleted_segments: u64,
234 pub deleted_other_objects: u64,
236 pub namespace_reaped: bool,
238 pub retained_candidates: u64,
240 pub namespace_degraded: bool,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub next_cursor: Option<String>,
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 #[test]
252 fn grep_paths_keep_the_plain_string_wire_shape() {
253 let request = GrepRequest {
254 pattern: "needle".to_owned(),
255 case_insensitive: false,
256 path_prefix: Some(AbsolutePath::parse("/docs").expect("path prefix")),
257 cursor: None,
258 limit: None,
259 allow_stale: false,
260 allow_scan: false,
261 };
262 assert_eq!(
263 serde_json::to_value(request).expect("serialize grep request"),
264 serde_json::json!({
265 "pattern": "needle",
266 "case_insensitive": false,
267 "path_prefix": "/docs",
268 "allow_stale": false,
269 "allow_scan": false
270 })
271 );
272
273 let found = GrepMatch {
274 path: AbsolutePath::parse("/docs/a.txt").expect("match path"),
275 inode_id: InodeId(2),
276 revision_no: RevisionNo(3),
277 line_number: 4,
278 byte_offset: 5,
279 line: "needle".to_owned(),
280 line_truncated: false,
281 };
282 assert_eq!(
283 serde_json::to_value(found).expect("serialize grep match"),
284 serde_json::json!({
285 "path": "/docs/a.txt",
286 "inode_id": "ino_2",
287 "revision_no": 3,
288 "line_number": 4,
289 "byte_offset": 5,
290 "line": "needle",
291 "line_truncated": false
292 })
293 );
294 }
295
296 #[test]
297 fn lifecycle_statuses_never_share_a_sequence_field() {
298 let backfilling = GrepIndexLifecycle::Backfilling {
299 target_seq: ChangeSeq(9),
300 cursor_inode_id: Some(InodeId(4)),
301 checkpoint_id: CheckpointId::parse("chk_00000000000000000000000000000009")
302 .expect("checkpoint id"),
303 };
304 assert_eq!(
305 serde_json::to_value(&backfilling).expect("serialize backfilling"),
306 serde_json::json!({
307 "status": "backfilling",
308 "target_seq": 9,
309 "cursor_inode_id": "ino_4",
310 "checkpoint_id": "chk_00000000000000000000000000000009"
311 }),
312 "a backfill reports its target and its walk, never a watermark"
313 );
314
315 assert_eq!(
316 serde_json::to_value(GrepIndexLifecycle::Active {
317 built_through_seq: ChangeSeq(9),
318 next_event_index: 0,
319 })
320 .expect("serialize active"),
321 serde_json::json!({"status": "active", "built_through_seq": 9}),
322 "an active index reports its watermark and no target"
323 );
324
325 assert_eq!(
326 serde_json::to_value(GrepIndexLifecycle::Disabled).expect("serialize disabled"),
327 serde_json::json!({"status": "disabled"})
328 );
329 }
330
331 #[test]
332 fn only_an_active_index_has_built_through_a_sequence() {
333 let backfilling = GrepIndexLifecycle::Backfilling {
334 target_seq: ChangeSeq(9),
335 cursor_inode_id: None,
336 checkpoint_id: CheckpointId::parse("chk_00000000000000000000000000000009")
337 .expect("checkpoint id"),
338 };
339 assert!(
340 !backfilling.is_built_through(ChangeSeq(0)),
341 "a backfill has indexed nothing until it turns active"
342 );
343 assert!(!GrepIndexLifecycle::Disabled.is_built_through(ChangeSeq(0)));
344
345 let active = |built_through_seq, next_event_index| GrepIndexLifecycle::Active {
346 built_through_seq,
347 next_event_index,
348 };
349 assert!(active(ChangeSeq(9), 0).is_built_through(ChangeSeq(9)));
350 assert!(active(ChangeSeq(9), 0).is_built_through(ChangeSeq(8)));
351 assert!(!active(ChangeSeq(9), 0).is_built_through(ChangeSeq(10)));
352 assert!(!active(ChangeSeq(9), 3).is_built_through(ChangeSeq(9)));
355 assert!(active(ChangeSeq(9), 3).is_built_through(ChangeSeq(8)));
356 }
357
358 #[test]
359 fn grep_index_status_flattens_active_lifecycle() {
360 let response = GrepIndexStatusResponse {
361 namespace_id: NamespaceId::parse("demo").expect("namespace id"),
362 lifecycle: GrepIndexLifecycle::Active {
363 built_through_seq: ChangeSeq(12),
364 next_event_index: 0,
365 },
366 next_run_ordinal: 3,
367 reorganize_pending: false,
368 };
369
370 assert_eq!(
371 serde_json::to_string(&response).expect("serialize active status"),
372 r#"{"namespace_id":"demo","status":"active","built_through_seq":12,"next_run_ordinal":3,"reorganize_pending":false}"#
373 );
374 }
375
376 #[test]
377 fn grep_index_status_flattens_backfilling_lifecycle() {
378 let response = GrepIndexStatusResponse {
379 namespace_id: NamespaceId::parse("demo").expect("namespace id"),
380 lifecycle: GrepIndexLifecycle::Backfilling {
381 target_seq: ChangeSeq(12),
382 cursor_inode_id: Some(InodeId(4)),
383 checkpoint_id: CheckpointId::parse("chk_00000000000000000000000000000009")
384 .expect("checkpoint id"),
385 },
386 next_run_ordinal: 1,
387 reorganize_pending: false,
388 };
389
390 assert_eq!(
391 serde_json::to_string(&response).expect("serialize backfilling status"),
392 r#"{"namespace_id":"demo","status":"backfilling","target_seq":12,"cursor_inode_id":"ino_4","checkpoint_id":"chk_00000000000000000000000000000009","next_run_ordinal":1,"reorganize_pending":false}"#
393 );
394 }
395
396 #[test]
397 fn grep_path_prefix_validates_during_deserialization() {
398 let encoded = serde_json::json!({
399 "pattern": "needle",
400 "path_prefix": "relative/path"
401 });
402
403 assert!(serde_json::from_value::<GrepRequest>(encoded).is_err());
404 }
405
406 #[test]
410 fn search_request_bodies_reject_unknown_fields() {
411 serde_json::from_value::<GrepRequest>(serde_json::json!({
412 "pattern": "needle",
413 "case_insensitive": true,
414 "path_prefix": "/docs",
415 "limit": 10,
416 "allow_stale": true,
417 "allow_scan": true
418 }))
419 .expect("the same body without a typo decodes");
420
421 for body in [
422 serde_json::json!({"pattern": "needle", "case_insensitve": true}),
423 serde_json::json!({"pattern": "needle", "caseInsensitive": true}),
424 serde_json::json!({"pattern": "needle", "pathPrefix": "/docs"}),
425 serde_json::json!({"pattern": "needle", "allow_scans": true}),
426 ] {
427 assert!(
428 serde_json::from_value::<GrepRequest>(body.clone()).is_err(),
429 "an unknown field decoded instead of failing the search: {body}"
430 );
431 }
432
433 serde_json::from_value::<GrepGcRequest>(serde_json::json!({"max_objects": 8}))
434 .expect("the same collection body without a typo decodes");
435 assert!(
436 serde_json::from_value::<GrepGcRequest>(serde_json::json!({"maxObjects": 8})).is_err()
437 );
438 }
439}