krabka_remote_storage_topic/manager/
persistence.rs1use {
11 super::TopicBasedRemoteLogMetadataManager,
12 crate::{
13 error::{CodecError, SnapshotError},
14 log::PartitionStart,
15 snapshot::{SNAPSHOT_FILE_NAME, Snapshot},
16 },
17 std::sync::PoisonError,
18 tracing::instrument,
19};
20
21#[derive(Debug, PartialEq, Eq)]
24pub(super) struct ResumePlan {
25 pub committed: Vec<i64>,
26 pub assignment: Vec<PartitionStart>,
27}
28
29impl TopicBasedRemoteLogMetadataManager {
30 #[instrument(skip_all, err)]
39 pub(super) fn write_snapshot(&self) -> Result<i64, SnapshotError> {
40 let (committed_offsets, dump) = {
49 let applied = self.applied.lock().unwrap_or_else(PoisonError::into_inner);
50 let dump = self.inner.export();
51 (applied.clone(), dump)
52 };
53 let max = committed_offsets.iter().copied().max().unwrap_or(-1);
54 let snap = Snapshot {
55 committed_offsets,
56 dump,
57 };
58 let path = self.snapshot_dir.join(SNAPSHOT_FILE_NAME);
59 snap.write_atomic(&path)?;
60 Ok(max)
61 }
62
63 pub(super) fn resume_from_snapshot(
79 snapshot: Option<&Snapshot>,
80 n: usize,
81 ) -> Result<ResumePlan, SnapshotError> {
82 let mut committed = vec![-1i64; n];
83 if let Some(snap) = snapshot {
84 for (slot, &off) in committed.iter_mut().zip(&snap.committed_offsets) {
85 Self::resume_start_offset(off)?;
86 *slot = off;
87 }
88 }
89 let assignment = committed
91 .iter()
92 .zip(0..=i32::MAX)
93 .map(|(&off, partition)| {
94 Ok(PartitionStart {
95 partition,
96 start_offset: Self::resume_start_offset(off)?,
97 })
98 })
99 .collect::<Result<_, SnapshotError>>()?;
100 Ok(ResumePlan {
101 committed,
102 assignment,
103 })
104 }
105
106 pub(super) fn resume_start_offset(committed: i64) -> Result<i64, SnapshotError> {
110 krabka_verified::remote_metadata_resume_cursor(committed).ok_or_else(|| {
111 CodecError::Domain(format!(
112 "invalid remote-metadata committed offset {committed}"
113 ))
114 .into()
115 })
116 }
117
118 #[must_use]
123 pub fn committed_offset(&self, partition: i32) -> i64 {
124 usize::try_from(partition)
125 .ok()
126 .and_then(|i| self.committed_offsets.get(i).copied())
127 .unwrap_or(-1)
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use {
134 super::{ResumePlan, TopicBasedRemoteLogMetadataManager},
135 crate::{
136 log::{InProcessMetadataEventLog, MetadataEventLog},
137 manager::test_support::{
138 finish, on_blocking, seed, snapshot_test_dir, start_manager_in,
139 start_seeded_manager_in, started, tp, wait_ready,
140 },
141 partitioning::metadata_partition_for,
142 serde::MetadataEvent,
143 snapshot::{SNAPSHOT_FILE_NAME, Snapshot},
144 },
145 assert2::{assert, check},
146 krabka_ids::LeaderEpoch,
147 krabka_remote_storage::{PartitionDump, RemoteLogMetadataManager, RlmmCacheDump},
148 std::{
149 fs,
150 sync::Arc,
151 time::{Duration, Instant},
152 },
153 tokio::task,
154 };
155
156 fn snapshot_with_offsets(committed_offsets: Vec<i64>) -> Snapshot {
157 Snapshot {
158 committed_offsets,
159 dump: RlmmCacheDump::default(),
160 }
161 }
162
163 #[test]
164 fn startup_and_dynamic_resume_share_exact_cursor_rules() {
165 let snapshot = snapshot_with_offsets(vec![4]);
166 let first = TopicBasedRemoteLogMetadataManager::resume_from_snapshot(Some(&snapshot), 3)
167 .expect("valid cursors");
168 let retry = TopicBasedRemoteLogMetadataManager::resume_from_snapshot(Some(&snapshot), 3)
169 .expect("retry is deterministic");
170 check!(first == retry);
171 check!(first.committed == vec![4, -1, -1]);
172 check!(
173 first
174 .assignment
175 .iter()
176 .map(|p| p.start_offset)
177 .collect::<Vec<_>>()
178 == vec![5, 0, 0]
179 );
180 }
181
182 #[yare::parameterized(
183 sentinel = { -1, 0 },
184 ordinary = { 4, 5 },
185 last_valid = { i64::MAX - 1, i64::MAX },
186 )]
187 fn resume_start_offsets_match_committed_cursors(committed: i64, expected: i64) {
188 check!(
189 TopicBasedRemoteLogMetadataManager::resume_start_offset(committed).unwrap() == expected
190 );
191 }
192
193 #[yare::parameterized(
194 minimum = { i64::MIN },
195 below_sentinel = { -2 },
196 overflow = { i64::MAX },
197 )]
198 fn malformed_and_overflowing_snapshot_cursors_are_rejected(committed: i64) {
199 let snapshot = snapshot_with_offsets(vec![committed]);
200 check!(
201 TopicBasedRemoteLogMetadataManager::resume_from_snapshot(Some(&snapshot), 1).is_err()
202 );
203 check!(TopicBasedRemoteLogMetadataManager::resume_start_offset(committed).is_err());
204 }
205
206 #[tokio::test(flavor = "multi_thread")]
207 async fn oversized_snapshot_count_falls_back_to_replay() {
208 let dir = snapshot_test_dir("oversized-count");
209 fs::create_dir_all(&dir).unwrap();
210 fs::write(dir.join(SNAPSHOT_FILE_NAME), [
211 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 1,
212 ])
213 .unwrap();
214 let log: Arc<dyn MetadataEventLog> = InProcessMetadataEventLog::new(4);
215 let mp = metadata_partition_for(&tp(), log.partition_count()).unwrap();
216 log.publish(
217 mp,
218 MetadataEvent::AddSegment(started(10, 0, 99))
219 .encode()
220 .unwrap(),
221 )
222 .await
223 .unwrap();
224 log.publish(
225 mp,
226 MetadataEvent::UpdateSegment(finish(10)).encode().unwrap(),
227 )
228 .await
229 .unwrap();
230 let manager = start_manager_in(log, dir.clone()).unwrap();
231 check!(manager.committed_offset(mp) == -1);
232 manager.reconcile_assignment(&[mp]).await;
233 wait_ready(&manager, &tp()).await;
234 check!(manager.list_remote_log_segments(&tp()).unwrap().len() == 1);
235 manager.shutdown_and_flush().await;
236 fs::remove_dir_all(dir).unwrap();
237 }
238
239 #[tokio::test(flavor = "multi_thread")]
240 async fn invalid_snapshot_cursor_discards_stale_dump_and_replays_from_zero() {
241 let dir = snapshot_test_dir("invalid-cursor");
242 let log: Arc<dyn MetadataEventLog> = InProcessMetadataEventLog::new(4);
243 let mp = metadata_partition_for(&tp(), log.partition_count()).unwrap();
244 let mut committed_offsets = vec![-1; 4];
245 committed_offsets[usize::try_from(mp).unwrap()] = i64::MAX;
246 let snapshot = Snapshot {
247 committed_offsets,
248 dump: RlmmCacheDump {
249 partitions: vec![PartitionDump {
250 topic_id_partition: tp(),
251 segments: vec![started(99, 0, 99)],
252 delete_state: None,
253 }],
254 },
255 };
256 snapshot
257 .write_atomic(&dir.join(SNAPSHOT_FILE_NAME))
258 .expect("write invalid semantic snapshot");
259
260 let manager =
261 start_manager_in(log, dir.clone()).expect("invalid cursor falls back to full replay");
262 check!(manager.committed_offset(mp) == -1);
263 manager.reconcile_assignment(&[mp]).await;
264 check!(manager.metadata_partition_ready(mp));
265 check!(manager.list_remote_log_segments(&tp()).unwrap().is_empty());
266 manager.shutdown();
267 let _ = fs::remove_dir_all(&dir);
268 }
269
270 #[tokio::test(flavor = "multi_thread")]
271 async fn stale_snapshot_cursor_replays_the_exact_lifecycle_suffix() {
272 let dir = snapshot_test_dir("stale-cursor");
273 let log: Arc<dyn MetadataEventLog> = InProcessMetadataEventLog::new(4);
274 let mp = metadata_partition_for(&tp(), log.partition_count()).unwrap();
275
276 let writer = start_manager_in(Arc::clone(&log), dir.clone()).unwrap();
277 writer.reconcile_assignment(&[mp]).await;
278 let writer_for_add = Arc::clone(&writer);
279 on_blocking(move || {
280 writer_for_add
281 .add_remote_log_segment_metadata(started(10, 0, 99))
282 .unwrap();
283 })
284 .await;
285 writer.shutdown_and_flush().await;
286
287 let suffix_offset = log
288 .publish(
289 mp,
290 MetadataEvent::UpdateSegment(finish(10)).encode().unwrap(),
291 )
292 .await
293 .expect("append lifecycle suffix");
294 check!(suffix_offset == 1);
295
296 let resumed = start_manager_in(log, dir.clone()).unwrap();
297 check!(resumed.committed_offset(mp) == 0);
298 resumed.reconcile_assignment(&[mp]).await;
299 let deadline = Instant::now() + Duration::from_secs(2);
300 while !resumed.metadata_partition_ready(mp) {
301 assert!(
302 Instant::now() < deadline,
303 "stale snapshot did not consume its exact suffix"
304 );
305 task::yield_now().await;
306 }
307 check!(
308 resumed
309 .highest_offset_for_epoch(&tp(), LeaderEpoch(0))
310 .unwrap()
311 == Some(99)
312 );
313 resumed.shutdown();
314 let _ = fs::remove_dir_all(&dir);
315 }
316
317 #[tokio::test(flavor = "multi_thread")]
318 async fn shutdown_flushes_a_snapshot_covering_applied_events() {
319 let dir = snapshot_test_dir("mgr-snap");
320 let log: Arc<dyn MetadataEventLog> = InProcessMetadataEventLog::new(4);
321 let m = start_seeded_manager_in(Arc::clone(&log), dir.clone(), &[seed(10, 0, 99)]).await;
322
323 m.shutdown_and_flush().await;
324
325 let path = dir.join(SNAPSHOT_FILE_NAME);
326 let snap = Snapshot::load(&path).unwrap().expect("snapshot written");
327 let p = metadata_partition_for(&tp(), 4).unwrap();
329 let idx = usize::try_from(p).unwrap();
330 check!(
331 snap.committed_offsets[idx] >= 1,
332 "committed >= last applied offset"
333 );
334 assert!(snap.dump.partitions.len() == 1);
336 check!(snap.dump.partitions[0].segments.len() == 1);
337 let _ = fs::remove_dir_all(&dir);
338 }
339
340 #[tokio::test(flavor = "multi_thread")]
341 async fn restart_resumes_from_snapshot_without_replaying_from_zero() {
342 let dir = snapshot_test_dir("resume");
343 let log: Arc<dyn MetadataEventLog> = InProcessMetadataEventLog::new(4);
344
345 let pre_cache;
347 {
348 let m = start_seeded_manager_in(Arc::clone(&log), dir.clone(), &[
349 seed(10, 0, 99),
350 seed(11, 100, 199),
351 seed(12, 200, 299),
352 ])
353 .await;
354 pre_cache = m.list_remote_log_segments(&tp()).unwrap();
355 m.shutdown_and_flush().await;
356 }
357
358 let p = metadata_partition_for(&tp(), 4).unwrap();
360 let idx = usize::try_from(p).unwrap();
361 let snap = Snapshot::load(&dir.join(SNAPSHOT_FILE_NAME))
362 .unwrap()
363 .expect("snapshot present");
364 let committed = snap.committed_offsets[idx];
365 assert!(
366 committed >= 5,
367 "6 events (3 add + 3 finish) → committed >= 5"
368 );
369
370 let ResumePlan {
373 committed: resumed_committed,
374 assignment,
375 } = TopicBasedRemoteLogMetadataManager::resume_from_snapshot(Some(&snap), 4)
376 .expect("valid snapshot cursor");
377 let orders_start = assignment
378 .iter()
379 .find(|s| s.partition == p)
380 .map(|s| s.start_offset)
381 .unwrap();
382 assert!(orders_start == committed + 1, "resume from N+1, not 0");
383 assert!(resumed_committed[idx] == committed);
384
385 let fresh = start_manager_in(Arc::clone(&log), dir.clone()).unwrap();
387 assert!(fresh.committed_offset(p) == committed);
390 fresh
395 .reconcile_assignment(&(0..log.partition_count()).collect::<Vec<_>>())
396 .await;
397 let deadline = Instant::now() + Duration::from_secs(5);
398 while !fresh.metadata_partition_ready(p) {
399 assert!(
400 Instant::now() < deadline,
401 "fresh manager did not catch up on the orders partition"
402 );
403 task::yield_now().await;
404 }
405 let post_cache = fresh.list_remote_log_segments(&tp()).unwrap();
406 assert!(
407 post_cache == pre_cache,
408 "post-load cache equals pre-restart cache"
409 );
410 assert!(
411 fresh
412 .highest_offset_for_epoch(&tp(), LeaderEpoch(0))
413 .unwrap()
414 == Some(299)
415 );
416 fresh.shutdown();
417 let _ = fs::remove_dir_all(&dir);
418 }
419}