krabka_remote_storage/diskless/capture/
mod.rs1mod authentication;
4mod projection;
5
6pub use projection::WalCaptureProjection;
7use {
8 super::WalIndexEntry,
9 crate::worm::{SegmentManifest, Sha256Digest},
10 serde::{Deserialize, Serialize},
11 std::collections::HashSet,
12 uuid::Uuid,
13};
14
15pub const CAPTURE_HEAD_NAME: &str = "diskless-capture";
17
18const CAPTURE_STATE_KEY: &str = "__krabka_diskless_capture_state";
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct CapturedWalRange {
23 pub object_key: String,
24 pub entry: WalIndexEntry,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct DisklessPartitionCapture {
30 pub topic: String,
31 pub topic_id: Uuid,
32 pub partition: i32,
33 pub delete_floor: i64,
34 pub recovery_cutoff: i64,
35 pub ranges: Vec<CapturedWalRange>,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct DisklessWalCapture {
41 pub format_version: u16,
42 pub captured_at_ms: u64,
43 pub source_cutoffs: Vec<i64>,
44 pub partitions: Vec<DisklessPartitionCapture>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub metadata_snapshot_sha256: Option<Sha256Digest>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub rlmm_snapshot_sha256: Option<Sha256Digest>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub group_offsets_sha256: Option<Sha256Digest>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub authentication: Option<SegmentManifest>,
57}
58
59#[derive(Deserialize)]
61struct DisklessWalCaptureVersion {
62 format_version: Option<serde_json::Value>,
63}
64
65impl DisklessWalCapture {
66 pub const FORMAT_VERSION: u16 = 1;
72
73 pub fn from_slice(bytes: &[u8]) -> Result<Self, String> {
82 let probe: DisklessWalCaptureVersion =
83 serde_json::from_slice(bytes).map_err(|error| error.to_string())?;
84 match probe.format_version {
85 None => {
86 return Err(
87 "diskless WAL capture has no format_version: it predates krabka \
88 1.0, which this build does not read"
89 .to_owned(),
90 );
91 }
92 Some(version) if version != Self::FORMAT_VERSION => {
93 return Err(format!(
94 "unsupported diskless WAL capture format version {version}: this build \
95 reads version {}",
96 Self::FORMAT_VERSION
97 ));
98 }
99 Some(_) => {}
100 }
101 let capture: Self = serde_json::from_slice(bytes).map_err(|error| error.to_string())?;
102 capture.validate()?;
103 Ok(capture)
104 }
105
106 pub fn validate(&self) -> Result<(), String> {
111 if self.source_cutoffs.iter().any(|cutoff| *cutoff < 0) {
112 return Err("negative diskless WAL source cutoff".to_owned());
113 }
114 let mut partitions = HashSet::new();
115 for partition in &self.partitions {
116 if !valid_topic_name(&partition.topic)
117 || partition.partition < 0
118 || partition.delete_floor < 0
119 || partition.recovery_cutoff < partition.delete_floor
120 || !partitions.insert((partition.topic_id, partition.partition))
121 {
122 return Err(format!(
123 "invalid or duplicate diskless capture partition {}-{}",
124 partition.topic, partition.partition
125 ));
126 }
127 let mut previous_last = None;
128 let mut covered_through = partition.delete_floor;
129 for range in &partition.ranges {
130 if range.object_key.is_empty()
131 || range.object_key == CAPTURE_STATE_KEY
132 || range.entry.topic_id != partition.topic_id
133 || range.entry.partition != partition.partition
134 || range.entry.first_offset < 0
135 || range.entry.last_offset < range.entry.first_offset
136 || range.entry.byte_len == 0
137 || previous_last.is_some_and(|last| last >= range.entry.first_offset)
138 {
139 return Err(format!(
140 "invalid or unordered diskless WAL range for {}-{}",
141 partition.topic, partition.partition
142 ));
143 }
144 if range.entry.last_offset >= partition.delete_floor {
145 if range.entry.first_offset > covered_through {
146 return Err(format!(
147 "diskless WAL ranges for {}-{} have a gap at {covered_through}",
148 partition.topic, partition.partition
149 ));
150 }
151 covered_through = range
152 .entry
153 .last_offset
154 .checked_add(1)
155 .ok_or_else(|| "diskless WAL recovery cutoff overflow".to_owned())?;
156 }
157 previous_last = Some(range.entry.last_offset);
158 }
159 let observed_cutoff = previous_last
160 .map(|last| {
161 last.checked_add(1)
162 .ok_or_else(|| "diskless WAL recovery cutoff overflow".to_owned())
163 })
164 .transpose()?
165 .unwrap_or(partition.delete_floor)
166 .max(partition.delete_floor);
167 if observed_cutoff != partition.recovery_cutoff {
168 return Err(format!(
169 "diskless WAL ranges for {}-{} end at {observed_cutoff}, capture declares {}",
170 partition.topic, partition.partition, partition.recovery_cutoff
171 ));
172 }
173 }
174 Ok(())
175 }
176}
177
178fn valid_topic_name(name: &str) -> bool {
179 !name.is_empty()
180 && name.len() <= 249
181 && name != "."
182 && name != ".."
183 && name
184 .bytes()
185 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
186}