1use {
10 crate::{
11 metadata::{CustomMetadata, RemoteLogSegmentMetadata, RemoteLogSegmentState},
12 worm::{
13 error::WormError,
14 manifest::{ChainHead, ChainStamp, EpochId, ManifestSeq},
15 },
16 },
17 krabka_verified::{ChainStep, chain::select_chain_tip, chain_step},
18 serde::{Deserialize, Serialize},
19};
20
21pub const WORM_CHAIN_RECORD_VERSION: i16 = 0;
27
28#[derive(Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31struct WormChainRecordJson {
32 version: i16,
33 epoch_id: EpochId,
34 seq: ManifestSeq,
35 prev_head: ChainHead,
36 head: Option<ChainHead>,
37 manifest_version_id: Option<String>,
38}
39
40#[derive(Deserialize)]
42struct WormChainRecordVersion {
43 version: Option<serde_json::Value>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct WormChainRecord {
60 pub epoch_id: EpochId,
62 pub seq: ManifestSeq,
64 pub prev_head: ChainHead,
66 pub head: Option<ChainHead>,
68 pub manifest_version_id: Option<String>,
71}
72
73impl WormChainRecord {
74 #[must_use]
76 pub fn request(stamp: ChainStamp) -> Self {
77 Self {
78 epoch_id: stamp.epoch_id,
79 seq: stamp.seq,
80 prev_head: stamp.prev_head,
81 head: None,
82 manifest_version_id: None,
83 }
84 }
85
86 #[must_use]
89 pub fn with_head(mut self, head: ChainHead) -> Self {
90 self.head = Some(head);
91 self
92 }
93
94 #[must_use]
96 pub fn with_manifest_version(mut self, version_id: Option<String>) -> Self {
97 self.manifest_version_id = version_id;
98 self
99 }
100
101 pub fn to_custom_metadata(&self) -> Result<CustomMetadata, WormError> {
109 serde_json::to_vec(&WormChainRecordJson {
110 version: WORM_CHAIN_RECORD_VERSION,
111 epoch_id: self.epoch_id,
112 seq: self.seq,
113 prev_head: self.prev_head,
114 head: self.head,
115 manifest_version_id: self.manifest_version_id.clone(),
116 })
117 .map(CustomMetadata)
118 .map_err(|e| WormError::MalformedChainRecord(e.to_string()))
119 }
120
121 pub fn from_custom_metadata(cm: &CustomMetadata) -> Result<Self, WormError> {
135 let malformed = |e: serde_json::Error| WormError::MalformedChainRecord(e.to_string());
136 let probe: WormChainRecordVersion = serde_json::from_slice(&cm.0).map_err(malformed)?;
137 let version = probe.version.map(|version| version.to_string());
138 if version.as_deref() != Some(WORM_CHAIN_RECORD_VERSION.to_string().as_str()) {
139 return Err(WormError::UnsupportedChainRecordVersion { found: version });
140 }
141 let json: WormChainRecordJson = serde_json::from_slice(&cm.0).map_err(malformed)?;
142 Ok(Self {
143 epoch_id: json.epoch_id,
144 seq: json.seq,
145 prev_head: json.prev_head,
146 head: json.head,
147 manifest_version_id: json.manifest_version_id,
148 })
149 }
150
151 #[must_use]
155 pub fn next_stamp(&self) -> Option<ChainStamp> {
156 let head = self.head?;
157 match chain_step(self.seq.0, self.seq.0, true) {
158 ChainStep::Continue(next) => Some(ChainStamp {
159 epoch_id: self.epoch_id,
160 seq: ManifestSeq(next),
161 prev_head: head,
162 }),
163 ChainStep::SequenceMismatch | ChainStep::HeadMismatch | ChainStep::Exhausted => None,
164 }
165 }
166}
167
168#[must_use]
189pub fn next_chain_stamp(
190 segments: &[RemoteLogSegmentMetadata],
191 new_epoch_id: EpochId,
192) -> Option<ChainStamp> {
193 let mut candidates = Vec::with_capacity(segments.len());
194 let mut receipts = Vec::with_capacity(segments.len());
195 for md in segments {
196 let receipt = if matches!(
197 md.state(),
198 RemoteLogSegmentState::DeleteSegmentStarted
199 | RemoteLogSegmentState::DeleteSegmentFinished
200 ) {
201 None
202 } else {
203 match md
204 .custom_metadata()
205 .map(WormChainRecord::from_custom_metadata)
206 {
207 Some(Ok(record)) => Some(record).filter(|record| record.head.is_some()),
208 Some(Err(WormError::UnsupportedChainRecordVersion { found: Some(_) })) => {
216 return None;
217 }
218 Some(Err(_)) | None => None,
219 }
220 };
221 let sequence = receipt.as_ref().map_or(0, |record| record.seq.0);
222 candidates.push((md.start_offset(), sequence, receipt.is_some()));
223 receipts.push(receipt);
224 }
225 let Some(index) = select_chain_tip(&candidates) else {
226 return Some(ChainStamp {
227 epoch_id: new_epoch_id,
228 seq: ManifestSeq(0),
229 prev_head: ChainHead::GENESIS,
230 });
231 };
232 receipts.get(index)?.as_ref()?.next_stamp()
233}
234
235#[cfg(test)]
236mod tests {
237 use {
238 super::*,
239 crate::metadata::{RemoteLogSegmentDetails, RemoteLogSegmentId, TopicIdPartition},
240 assert2::check,
241 krabka_ids::LeaderEpoch,
242 proptest::{collection::vec as prop_vec, num::u8::ANY as ANY_U8, proptest},
243 uuid::Uuid,
244 };
245
246 fn epoch() -> EpochId { EpochId(Uuid::from_u128(0x1234)) }
247
248 fn head(byte: u8) -> ChainHead { ChainHead([byte; 32]) }
249
250 fn request_stamp() -> ChainStamp {
251 ChainStamp {
252 epoch_id: epoch(),
253 seq: ManifestSeq(11),
254 prev_head: head(0x5a),
255 }
256 }
257
258 fn sample_metadata(
259 start_offset: i64,
260 state: RemoteLogSegmentState,
261 custom: Option<CustomMetadata>,
262 ) -> RemoteLogSegmentMetadata {
263 let md = RemoteLogSegmentMetadata::new(
264 RemoteLogSegmentId::new(
265 TopicIdPartition::new(Uuid::from_u128(1), "orders", 0),
266 Uuid::from_u128(u128::try_from(start_offset).unwrap() + 100),
267 ),
268 start_offset,
269 start_offset + 99,
270 123,
271 1,
272 456,
273 RemoteLogSegmentDetails::new(
274 8,
275 state,
276 maplit::btreemap! {LeaderEpoch(0) => start_offset},
277 ),
278 )
279 .unwrap();
280 match custom {
281 Some(cm) => md.with_custom_metadata(cm),
282 None => md,
283 }
284 }
285
286 fn receipt(seq: u64, prev: u8, produced: u8) -> CustomMetadata {
287 WormChainRecord::request(ChainStamp {
288 epoch_id: epoch(),
289 seq: ManifestSeq(seq),
290 prev_head: head(prev),
291 })
292 .with_head(head(produced))
293 .to_custom_metadata()
294 .unwrap()
295 }
296
297 #[test]
298 fn next_chain_stamp_starts_new_epoch_on_empty_partition() {
299 let fresh = EpochId(Uuid::from_u128(0xfeed));
300 check!(
301 next_chain_stamp(&[], fresh)
302 == Some(ChainStamp {
303 epoch_id: fresh,
304 seq: ManifestSeq(0),
305 prev_head: ChainHead::GENESIS,
306 })
307 );
308 }
309
310 #[test]
311 fn next_chain_stamp_continues_from_highest_offset_receipt() {
312 let segments = [
313 sample_metadata(
314 0,
315 RemoteLogSegmentState::CopySegmentFinished,
316 Some(receipt(0, 0x00, 0xaa)),
317 ),
318 sample_metadata(
319 200,
320 RemoteLogSegmentState::CopySegmentFinished,
321 Some(receipt(2, 0xbb, 0xcc)),
322 ),
323 sample_metadata(
324 100,
325 RemoteLogSegmentState::CopySegmentFinished,
326 Some(receipt(1, 0xaa, 0xbb)),
327 ),
328 ];
329 check!(
330 next_chain_stamp(&segments, EpochId(Uuid::from_u128(0xfeed)))
331 == Some(ChainStamp {
332 epoch_id: epoch(),
333 seq: ManifestSeq(3),
334 prev_head: head(0xcc),
335 })
336 );
337 }
338
339 #[test]
340 fn next_chain_stamp_breaks_offset_ties_by_greatest_seq() {
341 let request_at_same_offset = WormChainRecord::request(ChainStamp {
342 epoch_id: epoch(),
343 seq: ManifestSeq(9),
344 prev_head: head(0xcc),
345 })
346 .with_head(head(0xdd))
347 .to_custom_metadata()
348 .unwrap();
349 let segments = [
350 sample_metadata(
351 100,
352 RemoteLogSegmentState::CopySegmentFinished,
353 Some(receipt(1, 0xaa, 0xbb)),
354 ),
355 sample_metadata(
356 100,
357 RemoteLogSegmentState::CopySegmentFinished,
358 Some(request_at_same_offset),
359 ),
360 ];
361 check!(
362 next_chain_stamp(&segments, EpochId(Uuid::from_u128(0xfeed)))
363 == Some(ChainStamp {
364 epoch_id: epoch(),
365 seq: ManifestSeq(10),
366 prev_head: head(0xdd),
367 })
368 );
369 }
370
371 #[test]
372 fn next_chain_stamp_ignores_request_form_records() {
373 let request_only = WormChainRecord::request(ChainStamp {
374 epoch_id: epoch(),
375 seq: ManifestSeq(7),
376 prev_head: head(0xbb),
377 })
378 .to_custom_metadata()
379 .unwrap();
380 let segments = [
381 sample_metadata(
382 100,
383 RemoteLogSegmentState::CopySegmentFinished,
384 Some(receipt(1, 0xaa, 0xbb)),
385 ),
386 sample_metadata(
388 200,
389 RemoteLogSegmentState::CopySegmentStarted,
390 Some(request_only),
391 ),
392 ];
393 check!(
394 next_chain_stamp(&segments, EpochId(Uuid::from_u128(0xfeed)))
395 == Some(ChainStamp {
396 epoch_id: epoch(),
397 seq: ManifestSeq(2),
398 prev_head: head(0xbb),
399 })
400 );
401 }
402
403 #[yare::parameterized(
404 delete_started = { RemoteLogSegmentState::DeleteSegmentStarted },
405 delete_finished = { RemoteLogSegmentState::DeleteSegmentFinished },
406 )]
407 fn next_chain_stamp_ignores_deleted_segments(deleted_state: RemoteLogSegmentState) {
408 let expected_stamp = Some(ChainStamp {
409 epoch_id: epoch(),
410 seq: ManifestSeq(2),
411 prev_head: head(0xbb),
412 });
413 let segments = [
414 sample_metadata(
415 100,
416 RemoteLogSegmentState::CopySegmentFinished,
417 Some(receipt(1, 0xaa, 0xbb)),
418 ),
419 sample_metadata(300, deleted_state, Some(receipt(5, 0xee, 0xff))),
420 ];
421 let actual = next_chain_stamp(&segments, EpochId(Uuid::from_u128(0xfeed)));
422 check!(actual == expected_stamp);
423 }
424
425 #[yare::parameterized(
426 no_custom_metadata = { None },
427 empty_custom_metadata = { Some(CustomMetadata(Vec::new())) },
428 another_backend = { Some(CustomMetadata(b"s3://bucket/key".to_vec())) },
429 unrelated_json = { Some(CustomMetadata(br#"{"key":"value"}"#.to_vec())) },
430 bad_chain_head = {
431 Some(CustomMetadata(br#"{"epoch_id":"00000000-0000-0000-0000-000000000001","seq":1,"prev_head":"zz","head":null,"manifest_version_id":null}"#.to_vec())),
432 },
433 )]
434 fn next_chain_stamp_starts_new_epoch_when_no_receipt_decodes(custom: Option<CustomMetadata>) {
435 let fresh = EpochId(Uuid::from_u128(0xfeed));
436 let expected = ChainStamp {
437 epoch_id: fresh,
438 seq: ManifestSeq(0),
439 prev_head: ChainHead::GENESIS,
440 };
441 let segments = [sample_metadata(
442 100,
443 RemoteLogSegmentState::CopySegmentFinished,
444 custom,
445 )];
446 check!(next_chain_stamp(&segments, fresh) == Some(expected));
447 }
448
449 #[test]
450 fn next_chain_stamp_rejects_sequence_exhaustion() {
451 let exhausted = WormChainRecord::request(ChainStamp {
452 epoch_id: epoch(),
453 seq: ManifestSeq(u64::MAX),
454 prev_head: head(0xaa),
455 })
456 .with_head(head(0xbb))
457 .to_custom_metadata()
458 .unwrap();
459 let segments = [sample_metadata(
460 100,
461 RemoteLogSegmentState::CopySegmentFinished,
462 Some(exhausted),
463 )];
464
465 check!(next_chain_stamp(&segments, EpochId(Uuid::from_u128(0xfeed))) == None);
466 }
467
468 #[yare::parameterized(
469 request = {
470 WormChainRecord::request(request_stamp()),
471 },
472 receipt = {
473 WormChainRecord::request(request_stamp())
474 .with_head(head(0x6b))
475 .with_manifest_version(Some("3HL4kqtJlcpXroDTDmjVBH40Nrjfkd".to_owned())),
476 },
477 )]
478 fn chain_record_round_trips_through_custom_metadata(record: WormChainRecord) {
479 let encoded = record.to_custom_metadata().unwrap();
480 check!(WormChainRecord::from_custom_metadata(&encoded).unwrap() == record);
481 }
482
483 #[test]
484 fn chain_request_and_receipt_next_stamps_are_defined() {
485 let stamp = request_stamp();
486 let request = WormChainRecord::request(stamp);
487 let full = request
488 .clone()
489 .with_head(head(0x6b))
490 .with_manifest_version(Some("3HL4kqtJlcpXroDTDmjVBH40Nrjfkd".to_owned()));
491 check!(
492 request
493 == WormChainRecord {
494 epoch_id: epoch(),
495 seq: ManifestSeq(11),
496 prev_head: head(0x5a),
497 head: None,
498 manifest_version_id: None,
499 }
500 );
501 check!(request.next_stamp() == None);
502 check!(
503 full.next_stamp()
504 == Some(ChainStamp {
505 epoch_id: epoch(),
506 seq: ManifestSeq(12),
507 prev_head: head(0x6b),
508 })
509 );
510 }
511
512 fn golden_record() -> WormChainRecord {
513 WormChainRecord::request(request_stamp())
514 .with_head(head(0x6b))
515 .with_manifest_version(Some("v1".to_owned()))
516 }
517
518 const GOLDEN_RECORD: &str = concat!(
521 r#"{"version":0,"epoch_id":"00000000-0000-0000-0000-000000001234","seq":11,"#,
522 r#""prev_head":"5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a","#,
523 r#""head":"6b6b6b6b6b6b6b6b6b6b6b6b6b6b6b6b6b6b6b6b6b6b6b6b6b6b6b6b6b6b6b6b","#,
524 r#""manifest_version_id":"v1"}"#,
525 );
526
527 #[test]
528 fn chain_record_encodes_to_the_golden_bytes_and_decodes_back() {
529 check!(golden_record().to_custom_metadata().unwrap().0 == GOLDEN_RECORD.as_bytes());
530 check!(
531 WormChainRecord::from_custom_metadata(&CustomMetadata(
532 GOLDEN_RECORD.as_bytes().to_vec()
533 ))
534 .unwrap()
535 == golden_record()
536 );
537 }
538
539 fn custom_record_with_version(version: Option<serde_json::Value>) -> CustomMetadata {
540 let mut record: serde_json::Value = serde_json::from_str(GOLDEN_RECORD).unwrap();
541 let object = record.as_object_mut().unwrap();
542 object.remove("version");
543 if let Some(version) = version {
544 object.insert("version".to_owned(), version);
545 }
546 CustomMetadata(serde_json::to_vec(&record).unwrap())
547 }
548
549 #[yare::parameterized(
550 pre_1_0_no_version = { custom_record_with_version(None), None },
551 future_version = { custom_record_with_version(Some(serde_json::json!(1))), Some("1") },
552 wrong_type = { custom_record_with_version(Some(serde_json::json!("0"))), Some("\"0\"") },
553 )]
554 fn chain_record_of_a_missing_or_unknown_version_is_refused(
555 custom: CustomMetadata,
556 found: Option<&str>,
557 ) {
558 let err = WormChainRecord::from_custom_metadata(&custom).unwrap_err();
559 assert2::assert!(
560 let WormError::UnsupportedChainRecordVersion { found: actual } = &err,
561 "{err}"
562 );
563 check!(actual.as_deref() == found);
564 }
565
566 #[test]
567 fn next_chain_stamp_refuses_to_continue_past_a_record_of_an_unknown_version() {
568 let mut future: serde_json::Value = serde_json::from_str(GOLDEN_RECORD).unwrap();
569 future["version"] = serde_json::json!(1);
570 let segments = [
571 sample_metadata(
572 100,
573 RemoteLogSegmentState::CopySegmentFinished,
574 Some(receipt(3, 0x01, 0x02)),
575 ),
576 sample_metadata(
577 50,
578 RemoteLogSegmentState::CopySegmentFinished,
579 Some(CustomMetadata(serde_json::to_vec(&future).unwrap())),
580 ),
581 ];
582
583 check!(next_chain_stamp(&segments, EpochId(Uuid::from_u128(0xfeed))) == None);
584 }
585
586 #[test]
587 fn malformed_chain_record_is_an_error_not_a_panic() {
588 let err = WormChainRecord::from_custom_metadata(&CustomMetadata(b"not json".to_vec()))
589 .unwrap_err();
590 check!(matches!(err, WormError::MalformedChainRecord(_)));
591 }
592
593 proptest! {
594 #[test]
595 fn from_custom_metadata_never_panics_on_arbitrary_bytes(
596 bytes in prop_vec(ANY_U8, 0..512usize),
597 ) {
598 let _ = WormChainRecord::from_custom_metadata(&CustomMetadata(bytes));
599 }
600 }
601}