use crate::{AbsolutePath, ChangeSeq, CheckpointId, InodeId, NamespaceId, RevisionNo};
use serde::{Deserialize, Serialize};
use xxhash_rust::xxh64::xxh64;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct GrepRequest {
pub pattern: String,
#[serde(default)]
pub case_insensitive: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path_prefix: Option<AbsolutePath>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(default)]
pub allow_stale: bool,
#[serde(default)]
pub allow_scan: bool,
}
impl GrepRequest {
pub fn fingerprint(&self) -> u64 {
let mut seed = xxh64(self.pattern.as_bytes(), 0);
seed = xxh64(
self.path_prefix
.as_ref()
.map(AbsolutePath::as_str)
.unwrap_or("")
.as_bytes(),
seed,
);
let flags = [
u8::from(self.case_insensitive),
u8::from(self.allow_stale),
u8::from(self.allow_scan),
];
xxh64(&flags, seed)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct GrepMatch {
pub path: AbsolutePath,
#[serde(with = "crate::public_inode_id")]
#[cfg_attr(
feature = "openapi",
schema(schema_with = crate::public_inode_id::schema)
)]
pub inode_id: InodeId,
pub revision_no: RevisionNo,
pub line_number: u64,
pub byte_offset: u64,
pub line: String,
#[serde(default)]
pub line_truncated: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct GrepResponse {
pub namespace_id: NamespaceId,
pub head_seq: ChangeSeq,
pub built_through_seq: ChangeSeq,
pub tail_scanned: bool,
pub matches: Vec<GrepMatch>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum GrepIndexLifecycle {
Disabled,
Backfilling {
target_seq: ChangeSeq,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::public_inode_id::option"
)]
#[cfg_attr(
feature = "openapi",
schema(schema_with = crate::public_inode_id::optional_schema)
)]
cursor_inode_id: Option<InodeId>,
checkpoint_id: CheckpointId,
},
Active {
built_through_seq: ChangeSeq,
#[serde(default, skip_serializing_if = "is_zero")]
next_event_index: u32,
},
}
impl GrepIndexLifecycle {
pub fn is_built_through(&self, target_seq: ChangeSeq) -> bool {
match self {
Self::Disabled | Self::Backfilling { .. } => false,
Self::Active {
built_through_seq,
next_event_index,
} => {
*built_through_seq > target_seq
|| (*built_through_seq == target_seq && *next_event_index == 0)
}
}
}
}
fn is_zero(value: &u32) -> bool {
*value == 0
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct GrepIndexStatusResponse {
pub namespace_id: NamespaceId,
#[serde(flatten)]
pub lifecycle: GrepIndexLifecycle,
#[cfg_attr(feature = "openapi", schema(maximum = 9007199254740991_u64))]
pub next_run_ordinal: u64,
pub reorganize_pending: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct GrepGcRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_objects: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct GrepGcResponse {
pub namespace_id: NamespaceId,
pub deleted_segments: u64,
pub deleted_other_objects: u64,
pub namespace_reaped: bool,
pub retained_candidates: u64,
pub namespace_degraded: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn grep_paths_keep_the_plain_string_wire_shape() {
let request = GrepRequest {
pattern: "needle".to_owned(),
case_insensitive: false,
path_prefix: Some(AbsolutePath::parse("/docs").expect("path prefix")),
cursor: None,
limit: None,
allow_stale: false,
allow_scan: false,
};
assert_eq!(
serde_json::to_value(request).expect("serialize grep request"),
serde_json::json!({
"pattern": "needle",
"case_insensitive": false,
"path_prefix": "/docs",
"allow_stale": false,
"allow_scan": false
})
);
let found = GrepMatch {
path: AbsolutePath::parse("/docs/a.txt").expect("match path"),
inode_id: InodeId(2),
revision_no: RevisionNo(3),
line_number: 4,
byte_offset: 5,
line: "needle".to_owned(),
line_truncated: false,
};
assert_eq!(
serde_json::to_value(found).expect("serialize grep match"),
serde_json::json!({
"path": "/docs/a.txt",
"inode_id": "ino_2",
"revision_no": 3,
"line_number": 4,
"byte_offset": 5,
"line": "needle",
"line_truncated": false
})
);
}
#[test]
fn lifecycle_statuses_never_share_a_sequence_field() {
let backfilling = GrepIndexLifecycle::Backfilling {
target_seq: ChangeSeq(9),
cursor_inode_id: Some(InodeId(4)),
checkpoint_id: CheckpointId::parse("chk_00000000000000000000000000000009")
.expect("checkpoint id"),
};
assert_eq!(
serde_json::to_value(&backfilling).expect("serialize backfilling"),
serde_json::json!({
"status": "backfilling",
"target_seq": 9,
"cursor_inode_id": "ino_4",
"checkpoint_id": "chk_00000000000000000000000000000009"
}),
"a backfill reports its target and its walk, never a watermark"
);
assert_eq!(
serde_json::to_value(GrepIndexLifecycle::Active {
built_through_seq: ChangeSeq(9),
next_event_index: 0,
})
.expect("serialize active"),
serde_json::json!({"status": "active", "built_through_seq": 9}),
"an active index reports its watermark and no target"
);
assert_eq!(
serde_json::to_value(GrepIndexLifecycle::Disabled).expect("serialize disabled"),
serde_json::json!({"status": "disabled"})
);
}
#[test]
fn only_an_active_index_has_built_through_a_sequence() {
let backfilling = GrepIndexLifecycle::Backfilling {
target_seq: ChangeSeq(9),
cursor_inode_id: None,
checkpoint_id: CheckpointId::parse("chk_00000000000000000000000000000009")
.expect("checkpoint id"),
};
assert!(
!backfilling.is_built_through(ChangeSeq(0)),
"a backfill has indexed nothing until it turns active"
);
assert!(!GrepIndexLifecycle::Disabled.is_built_through(ChangeSeq(0)));
let active = |built_through_seq, next_event_index| GrepIndexLifecycle::Active {
built_through_seq,
next_event_index,
};
assert!(active(ChangeSeq(9), 0).is_built_through(ChangeSeq(9)));
assert!(active(ChangeSeq(9), 0).is_built_through(ChangeSeq(8)));
assert!(!active(ChangeSeq(9), 0).is_built_through(ChangeSeq(10)));
assert!(!active(ChangeSeq(9), 3).is_built_through(ChangeSeq(9)));
assert!(active(ChangeSeq(9), 3).is_built_through(ChangeSeq(8)));
}
#[test]
fn grep_index_status_flattens_active_lifecycle() {
let response = GrepIndexStatusResponse {
namespace_id: NamespaceId::parse("demo").expect("namespace id"),
lifecycle: GrepIndexLifecycle::Active {
built_through_seq: ChangeSeq(12),
next_event_index: 0,
},
next_run_ordinal: 3,
reorganize_pending: false,
};
assert_eq!(
serde_json::to_string(&response).expect("serialize active status"),
r#"{"namespace_id":"demo","status":"active","built_through_seq":12,"next_run_ordinal":3,"reorganize_pending":false}"#
);
}
#[test]
fn grep_index_status_flattens_backfilling_lifecycle() {
let response = GrepIndexStatusResponse {
namespace_id: NamespaceId::parse("demo").expect("namespace id"),
lifecycle: GrepIndexLifecycle::Backfilling {
target_seq: ChangeSeq(12),
cursor_inode_id: Some(InodeId(4)),
checkpoint_id: CheckpointId::parse("chk_00000000000000000000000000000009")
.expect("checkpoint id"),
},
next_run_ordinal: 1,
reorganize_pending: false,
};
assert_eq!(
serde_json::to_string(&response).expect("serialize backfilling status"),
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}"#
);
}
#[test]
fn grep_path_prefix_validates_during_deserialization() {
let encoded = serde_json::json!({
"pattern": "needle",
"path_prefix": "relative/path"
});
assert!(serde_json::from_value::<GrepRequest>(encoded).is_err());
}
#[test]
fn search_request_bodies_reject_unknown_fields() {
serde_json::from_value::<GrepRequest>(serde_json::json!({
"pattern": "needle",
"case_insensitive": true,
"path_prefix": "/docs",
"limit": 10,
"allow_stale": true,
"allow_scan": true
}))
.expect("the same body without a typo decodes");
for body in [
serde_json::json!({"pattern": "needle", "case_insensitve": true}),
serde_json::json!({"pattern": "needle", "caseInsensitive": true}),
serde_json::json!({"pattern": "needle", "pathPrefix": "/docs"}),
serde_json::json!({"pattern": "needle", "allow_scans": true}),
] {
assert!(
serde_json::from_value::<GrepRequest>(body.clone()).is_err(),
"an unknown field decoded instead of failing the search: {body}"
);
}
serde_json::from_value::<GrepGcRequest>(serde_json::json!({"max_objects": 8}))
.expect("the same collection body without a typo decodes");
assert!(
serde_json::from_value::<GrepGcRequest>(serde_json::json!({"maxObjects": 8})).is_err()
);
}
}