krabka_remote_storage/diskless/capture/
projection.rs1use {
4 super::{
5 super::{
6 WalIndexEntry,
7 index::{
8 WalDeleteFloorKey, WalDeleteFloorRecord, WalFlushRecord, WalIndexKey,
9 wal_index_key_version,
10 },
11 },
12 CapturedWalRange, DisklessPartitionCapture, DisklessWalCapture,
13 },
14 std::{
15 collections::{BTreeMap, HashMap},
16 hash::BuildHasher,
17 },
18 uuid::Uuid,
19};
20
21#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
23struct GroupKey {
24 topic: String,
25 topic_id: Uuid,
26 partition: i32,
27}
28
29#[derive(Default)]
31pub struct WalCaptureProjection {
32 ranges: HashMap<WalIndexKey, CapturedWalRange>,
33 floors: HashMap<WalDeleteFloorKey, i64>,
34}
35
36impl WalCaptureProjection {
37 pub fn apply(&mut self, key: Option<&[u8]>, value: Option<&[u8]>) -> Result<(), String> {
42 let key = key.ok_or_else(|| "diskless WAL index record has no key".to_owned())?;
46 if let Some(floor_key) = WalDeleteFloorKey::from_bytes(key) {
47 if let Some(value) = value {
48 let record = WalDeleteFloorRecord::from_bytes(value)?;
49 if record.topic_id != floor_key.topic_id
50 || record.partition != floor_key.partition
51 || record.floor < 0
52 {
53 return Err("diskless WAL delete-floor key/value mismatch".to_owned());
54 }
55 let floor = self.floors.entry(floor_key).or_default();
56 *floor = (*floor).max(record.floor);
57 } else {
58 self.floors.remove(&floor_key);
59 }
60 return Ok(());
61 }
62 let range_key =
63 WalIndexKey::from_bytes(key).ok_or_else(|| match wal_index_key_version(key) {
64 Some(version)
65 if version != WalIndexKey::KEY_VERSION
66 && version != WalDeleteFloorKey::KEY_VERSION =>
67 {
68 format!("unknown diskless WAL index key version {version}")
69 }
70 _ => "invalid diskless WAL index key".to_owned(),
71 })?;
72 let Some(value) = value else {
73 self.ranges.remove(&range_key);
74 return Ok(());
75 };
76 let record = WalFlushRecord::from_bytes(value)?;
77 let entry = record
78 .entries
79 .into_iter()
80 .find(|entry| WalIndexKey::from(entry) == range_key)
81 .ok_or_else(|| "diskless WAL index key/value mismatch".to_owned())?;
82 Self::validate_range(&entry)?;
83 self.store_range(range_key, record.object_key, entry);
84 Ok(())
85 }
86
87 fn validate_range(entry: &WalIndexEntry) -> Result<(), String> {
88 if entry.first_offset < 0 || entry.last_offset < entry.first_offset || entry.byte_len == 0 {
89 return Err("invalid diskless WAL index range".to_owned());
90 }
91 Ok(())
92 }
93
94 fn store_range(&mut self, key: WalIndexKey, object_key: String, entry: WalIndexEntry) {
95 self.ranges
96 .insert(key, CapturedWalRange { object_key, entry });
97 }
98
99 pub fn capture<S: BuildHasher>(
104 &self,
105 topics: &HashMap<Uuid, (String, i32), S>,
106 source_cutoffs: Vec<i64>,
107 captured_at_ms: u64,
108 ) -> Result<DisklessWalCapture, String> {
109 let mut grouped: BTreeMap<GroupKey, Vec<CapturedWalRange>> = BTreeMap::new();
110 for (topic_id, (topic, partition_count)) in topics {
111 if *partition_count < 0 {
112 return Err(format!(
113 "negative partition count for diskless topic {topic}"
114 ));
115 }
116 for partition in 0..*partition_count {
117 grouped
118 .entry(GroupKey {
119 topic: topic.clone(),
120 topic_id: *topic_id,
121 partition,
122 })
123 .or_default();
124 }
125 }
126 for range in self.ranges.values() {
127 let Some((topic, _)) = topics.get(&range.entry.topic_id) else {
128 continue;
129 };
130 grouped
131 .entry(GroupKey {
132 topic: topic.clone(),
133 topic_id: range.entry.topic_id,
134 partition: range.entry.partition,
135 })
136 .or_default()
137 .push(range.clone());
138 }
139 for WalDeleteFloorKey {
140 topic_id,
141 partition,
142 } in self.floors.keys()
143 {
144 let Some((topic, _)) = topics.get(topic_id) else {
145 continue;
146 };
147 grouped
148 .entry(GroupKey {
149 topic: topic.clone(),
150 topic_id: *topic_id,
151 partition: *partition,
152 })
153 .or_default();
154 }
155 let mut partitions = Vec::with_capacity(grouped.len());
156 for (
157 GroupKey {
158 topic,
159 topic_id,
160 partition,
161 },
162 mut ranges,
163 ) in grouped
164 {
165 ranges.sort_by_key(|range| range.entry.first_offset);
166 if ranges
167 .iter()
168 .zip(ranges.iter().skip(1))
169 .any(|(earlier, later)| earlier.entry.last_offset >= later.entry.first_offset)
170 {
171 return Err(format!(
172 "overlapping diskless WAL ranges for {topic}-{partition}"
173 ));
174 }
175 let mut runs: Vec<CapturedWalRange> = Vec::with_capacity(ranges.len());
176 for range in ranges {
177 let joins_previous = runs.last().is_some_and(|previous| {
178 previous.object_key == range.object_key
179 && previous.entry.last_offset.checked_add(1)
180 == Some(range.entry.first_offset)
181 && previous
182 .entry
183 .byte_start
184 .checked_add(u64::from(previous.entry.byte_len))
185 == Some(range.entry.byte_start)
186 });
187 if joins_previous {
188 if let Some(previous) = runs.last_mut() {
189 previous.entry.last_offset = range.entry.last_offset;
190 previous.entry.byte_len = previous
191 .entry
192 .byte_len
193 .checked_add(range.entry.byte_len)
194 .ok_or_else(|| {
195 format!("diskless WAL run exceeds 4 GiB for {topic}-{partition}")
196 })?;
197 previous.entry.max_timestamp_ms = previous
198 .entry
199 .max_timestamp_ms
200 .max(range.entry.max_timestamp_ms);
201 }
202 } else {
203 runs.push(range);
204 }
205 }
206 let ranges = runs;
207 let delete_floor = self
208 .floors
209 .get(&WalDeleteFloorKey {
210 topic_id,
211 partition,
212 })
213 .copied()
214 .unwrap_or(0);
215 let recovery_cutoff = ranges
216 .last()
217 .map_or(delete_floor, |range| {
218 range.entry.last_offset.saturating_add(1)
219 })
220 .max(delete_floor);
221 partitions.push(DisklessPartitionCapture {
222 topic,
223 topic_id,
224 partition,
225 delete_floor,
226 recovery_cutoff,
227 ranges,
228 });
229 }
230 Ok(DisklessWalCapture {
231 format_version: DisklessWalCapture::FORMAT_VERSION,
232 captured_at_ms,
233 source_cutoffs,
234 partitions,
235 metadata_snapshot_sha256: None,
236 rlmm_snapshot_sha256: None,
237 group_offsets_sha256: None,
238 authentication: None,
239 })
240 }
241}