Skip to main content

krabka_remote_storage_topic/manager/
persistence.rs

1//! Snapshot capture for the local cache and the resume computation that reads
2//! it back.
3//!
4//! The manager writes an on-disk snapshot so a restart resumes each metadata
5//! partition from its committed offset instead of replaying the whole topic.
6//! This module holds the write side, the canonical `committed + 1` resume
7//! policy, and the accessor the assignment reconciler uses, so the one place
8//! that owns that policy is easy to find.
9
10use {
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/// Startup state derived from a snapshot: the committed offsets and the
22/// consumer assignment that resumes after them.
23#[derive(Debug, PartialEq, Eq)]
24pub(super) struct ResumePlan {
25    pub committed: Vec<i64>,
26    pub assignment: Vec<PartitionStart>,
27}
28
29impl TopicBasedRemoteLogMetadataManager {
30    /// Capture the pump's committed offsets together with a cache export
31    /// under a consistent lock, and write a snapshot.
32    ///
33    /// This method holds the `applied` lock only long enough to clone the
34    /// offsets and run `export()`, which takes the inner partitions lock. No
35    /// Kafka round-trip happens inside, so the hold is bounded. Returns the
36    /// highest committed offset written, for the "advanced since last"
37    /// check.
38    #[instrument(skip_all, err)]
39    pub(super) fn write_snapshot(&self) -> Result<i64, SnapshotError> {
40        // Benign-replay invariant: the pump updates `inner` BEFORE bumping
41        // `applied`, so the captured cache may lead the captured committed
42        // offset by at most one event (the in-flight one). On resume that
43        // single event is replayed from committed+1 and harmlessly
44        // re-rejected: a re-applied AddSegment hits already-exists, and a
45        // re-applied finished→finished update is a no-op. The dangerous
46        // direction — cache BEHIND committed, which would skip an event on
47        // resume — cannot occur because inner is always updated first.
48        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    /// Build startup state from the same proved cursor adapter used by
64    /// dynamic assignment.
65    ///
66    /// Given an already-loaded snapshot, or `None` for a missing or corrupt
67    /// one, and the metadata-partition count `n`, this function produces:
68    ///
69    /// - the per-partition committed offsets, indexed by metadata partition
70    ///   and padded or truncated to `n`. `-1` means no committed event, so
71    ///   that partition needs a full replay.
72    /// - the metadata-consumer assignment that resumes each partition at
73    ///   `committed + 1`.
74    ///
75    /// Invalid committed offsets reject the snapshot before its cache dump is
76    /// imported. [`Self::resume_start_offset`] is the only place the
77    /// `committed + 1` policy lives; do not recompute it elsewhere.
78    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        // The metadata partition count came from an i32.
90        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    /// The one adapter from a stored committed offset to an inclusive
107    /// consumer cursor. Startup and dynamic assignment both call this exact
108    /// function, so their sentinel and overflow rules cannot drift.
109    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    /// Committed offset loaded from the snapshot for a single metadata
119    /// partition, or `-1` when the partition is out of range or had no
120    /// committed event and so needs a full replay. The assignment reconciler
121    /// uses this to start a dynamically-added partition at `committed + 1`.
122    #[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        // The orders partition's committed offset covers both events.
328        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        // The dump contains the finished segment.
335        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        // First lifetime: seed three finished segments, then shutdown-flush.
346        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        // Snapshot now records committed offset N for the orders partition.
359        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        // The canonical resume computation resumes the orders partition at
371        // committed + 1 (same path start() uses).
372        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        // Second lifetime against the SAME log + dir: must resume, not replay.
386        let fresh = start_manager_in(Arc::clone(&log), dir.clone()).unwrap();
387        // The manager exposes the same committed offset via its canonical
388        // accessor used by the assignment reconciler.
389        assert!(fresh.committed_offset(p) == committed);
390        // Assign every partition and wait for catch-up so the gated read
391        // methods delegate to the (snapshot-seeded) inner cache. The orders
392        // partition has no backlog past `committed`, so it is ready as soon
393        // as the assignment-time HWM is recorded.
394        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}