1use {
15 crate::{
16 error::LogError,
17 index::{read_sidecar, rewrite_retained_entries, rewrite_sidecar_file},
18 },
19 krabka_ids::{Offset, ProducerId},
20 std::{collections::HashSet, fs::OpenOptions, io::Write, mem, path::PathBuf},
21 tracing::instrument,
22 zerocopy::{
23 BigEndian, FromBytes, Immutable, IntoBytes, KnownLayout, Unaligned,
24 byteorder::{I16, I64},
25 },
26};
27
28const ENTRY_BYTES: usize = 34;
29
30const SUPPORTED_VERSION: i16 = 0;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34pub struct AbortedTxn {
35 pub start_offset: Offset,
36 pub last_offset: Offset,
37 pub producer_id: ProducerId,
38 pub last_stable_offset: Offset,
41}
42
43impl AbortedTxn {
44 #[must_use]
49 pub fn overlaps(&self, start: Offset, end: Offset) -> bool {
50 krabka_verified::aborted_transaction_overlaps(
51 self.start_offset.0,
52 self.last_offset.0,
53 start.0,
54 end.0,
55 )
56 }
57}
58
59#[derive(Debug, Clone, Copy, FromBytes, IntoBytes, KnownLayout, Immutable, Unaligned)]
62#[repr(C)]
63struct AbortedTxnRaw {
64 version: I16<BigEndian>,
65 producer_id: I64<BigEndian>,
66 first_offset: I64<BigEndian>,
67 last_offset: I64<BigEndian>,
68 last_stable_offset: I64<BigEndian>,
69}
70
71const _: [(); ENTRY_BYTES] = [(); mem::size_of::<AbortedTxnRaw>()];
72
73impl AbortedTxnRaw {
74 fn new(entry: AbortedTxn) -> Self {
75 Self {
76 version: I16::new(SUPPORTED_VERSION),
77 producer_id: I64::new(entry.producer_id.0),
78 first_offset: I64::new(entry.start_offset.0),
79 last_offset: I64::new(entry.last_offset.0),
80 last_stable_offset: I64::new(entry.last_stable_offset.0),
81 }
82 }
83}
84
85#[derive(Debug)]
86pub struct TxnIndex {
87 path: PathBuf,
88 entries: Vec<AbortedTxn>,
89 identities: HashSet<AbortedTxn>,
92 on_disk: bool,
95}
96
97impl TxnIndex {
98 #[krabka_macros::index_open]
99 pub fn open(path: PathBuf) -> Result<Self, LogError> {
105 let read = read_sidecar::<AbortedTxnRaw, _>(
106 &path,
107 "txnindex",
108 "length is a multiple of ENTRY_BYTES and AbortedTxnRaw is Unaligned",
109 |raws| {
110 let mut entries = Vec::with_capacity(raws.len());
111 let mut identities = HashSet::with_capacity(raws.len());
112 for raw in raws {
113 if raw.version.get() != SUPPORTED_VERSION {
114 return Err(LogError::Corrupt(format!(
115 "txnindex {} contains an unsupported record version {}",
116 path.display(),
117 raw.version.get(),
118 )));
119 }
120 let entry = AbortedTxn {
121 start_offset: Offset(raw.first_offset.get()),
122 last_offset: Offset(raw.last_offset.get()),
123 producer_id: ProducerId(raw.producer_id.get()),
124 last_stable_offset: Offset(raw.last_stable_offset.get()),
125 };
126 if !Self::entry_valid(entry) {
127 return Err(LogError::Corrupt(format!(
128 "txnindex {} contains an invalid aborted interval",
129 path.display()
130 )));
131 }
132 if identities.insert(entry) {
133 entries.push(entry);
134 }
135 }
136 Ok((entries, identities))
137 },
138 )?;
139 let on_disk = read.is_some();
141 let (entries, identities) = read.unwrap_or_default();
142 tracing::Span::current().record("entries", entries.len());
143 Ok(Self {
144 path,
145 entries,
146 identities,
147 on_disk,
148 })
149 }
150
151 #[instrument(
153 level = "debug",
154 skip(self),
155 fields(producer_id = entry.producer_id.0),
156 err,
157 )]
158 pub fn append(&mut self, entry: AbortedTxn) -> Result<(), LogError> {
161 if !Self::entry_valid(entry) {
162 return Err(LogError::InvalidArgument(format!(
163 "invalid aborted transaction {}..={} for producer {}",
164 entry.start_offset, entry.last_offset, entry.producer_id
165 )));
166 }
167 if self.identities.contains(&entry) {
168 return Ok(());
169 }
170 let mut f = OpenOptions::new()
171 .create(true)
172 .append(true)
173 .open(&self.path)
174 .map_err(LogError::Io)?;
175 self.on_disk = true;
176 let raw = AbortedTxnRaw::new(entry);
177 f.write_all(raw.as_bytes()).map_err(LogError::Io)?;
178 f.sync_data().map_err(LogError::Io)?;
179 self.entries.push(entry);
180 self.identities.insert(entry);
181 Ok(())
182 }
183
184 pub fn truncate_from(&mut self, offset: Offset) -> Result<(), LogError> {
192 if let Some(entries) = rewrite_retained_entries(
193 &self.entries,
194 |entry| entry.last_offset < offset,
195 |entries| {
196 let mut file = rewrite_sidecar_file(&self.path)?;
197 for entry in entries {
198 let raw = AbortedTxnRaw::new(*entry);
199 file.write_all(raw.as_bytes()).map_err(LogError::Io)?;
200 }
201 file.sync_data().map_err(LogError::Io)
202 },
203 )? {
204 self.entries = entries;
205 self.identities = self.entries.iter().copied().collect();
206 }
207 Ok(())
208 }
209
210 #[must_use]
211 pub fn entries(&self) -> &[AbortedTxn] { &self.entries }
212
213 #[must_use]
215 pub fn on_disk(&self) -> bool { self.on_disk }
216
217 fn entry_valid(entry: AbortedTxn) -> bool {
218 krabka_verified::aborted_transaction_interval(
219 Some(entry.start_offset.0),
220 entry.last_offset.0,
221 entry.producer_id.get(),
222 ) == Some((entry.start_offset.0, entry.last_offset.0))
223 }
224
225 pub fn collect_aborted(&self, start: Offset, end: Offset, out: &mut Vec<AbortedTxn>) -> bool {
235 for entry in &self.entries {
236 if krabka_verified::aborted_transaction_overlaps(
237 entry.start_offset.0,
238 entry.last_offset.0,
239 start.0,
240 end.0,
241 ) {
242 out.push(*entry);
243 }
244 if entry.last_stable_offset >= end {
245 return true;
246 }
247 }
248 false
249 }
250
251 pub fn aborted_in_range(
253 &self,
254 start: Offset,
255 end: Offset,
256 ) -> impl Iterator<Item = &AbortedTxn> {
257 self.entries.iter().filter(move |entry| {
258 krabka_verified::aborted_transaction_overlaps(
259 entry.start_offset.0,
260 entry.last_offset.0,
261 start.0,
262 end.0,
263 )
264 })
265 }
266}
267
268#[cfg(test)]
269mod tests {
270
271 use {
272 super::*,
273 crate::{index::IndexFixture, test_support::aborted_txn},
274 std::{fs, iter, path::Path},
275 tempfile::TempDir,
276 };
277
278 fn populated_index(name: &str, entries: &[AbortedTxn]) -> IndexFixture<TxnIndex> {
279 let dir = TempDir::new().unwrap();
280 let path = dir.path().join(name);
281 let mut index = TxnIndex::open(path.clone()).unwrap();
282 for &entry in entries {
283 index.append(entry).unwrap();
284 }
285 IndexFixture { dir, path, index }
286 }
287
288 fn retry_entry() -> AbortedTxn { aborted_txn().bounds(Offset(5)..=Offset(7)).stable(8).call() }
290
291 #[derive(Clone, Copy, krabka_macros::FieldDefaults)]
292 struct RangedIndexSetup<'a> {
293 #[default("00.txnindex")]
294 name: &'a str,
295 }
296
297 #[bon::builder]
299 fn ranged_index(#[builder(default = "00.txnindex")] name: &'_ str) -> IndexFixture<TxnIndex> {
300 let setup = RangedIndexSetup { name };
301 populated_index(setup.name, &[aborted_txn()
302 .producer(1)
303 .bounds(Offset(10)..=Offset(20))
304 .stable(21)
305 .call()])
306 }
307
308 struct MalformedEntry {
310 name: &'static str,
311 entry: AbortedTxn,
312 }
313
314 fn malformed_entries() -> [MalformedEntry; 2] {
316 [
317 MalformedEntry {
318 name: "inverted",
319 entry: aborted_txn()
320 .producer(1)
321 .bounds(Offset(8)..=Offset(7))
322 .stable(8)
323 .call(),
324 },
325 MalformedEntry {
326 name: "negative-producer",
327 entry: aborted_txn()
328 .producer(-1)
329 .bounds(Offset(7)..=Offset(8))
330 .stable(9)
331 .call(),
332 },
333 ]
334 }
335
336 fn write_entry(path: &Path, entry: AbortedTxn) {
337 let raw = AbortedTxnRaw::new(entry);
338 fs::write(path, raw.as_bytes()).unwrap();
339 }
340
341 #[test]
345 fn append_writes_the_exact_kafka_wire_layout() {
346 let entry = aborted_txn()
347 .bounds(Offset(5)..=Offset(9))
348 .stable(10)
349 .call();
350 let IndexFixture {
351 dir: _dir,
352 path,
353 index: _index,
354 } = populated_index("00.txnindex", &[entry]);
355
356 let bytes = fs::read(&path).unwrap();
357 let mut expected = Vec::new();
358 expected.extend_from_slice(&0_i16.to_be_bytes()); expected.extend_from_slice(&1000_i64.to_be_bytes()); expected.extend_from_slice(&5_i64.to_be_bytes()); expected.extend_from_slice(&9_i64.to_be_bytes()); expected.extend_from_slice(&10_i64.to_be_bytes()); assert2::assert!(bytes.len() == ENTRY_BYTES);
364 assert2::assert!(bytes == expected);
365 }
366
367 #[test]
370 fn three_entries_round_trip_at_the_exact_kafka_entry_size() {
371 let dir = TempDir::new().unwrap();
372 let path = dir.path().join("00.txnindex");
373 let entries = [
374 aborted_txn().call(),
375 aborted_txn()
376 .producer(2000)
377 .bounds(Offset(4)..=Offset(6))
378 .stable(7)
379 .call(),
380 aborted_txn()
381 .producer(3000)
382 .bounds(Offset(8)..=Offset(10))
383 .stable(11)
384 .call(),
385 ];
386 let mut index = TxnIndex::open(path.clone()).unwrap();
387 for entry in entries {
388 index.append(entry).unwrap();
389 }
390
391 let bytes = fs::read(&path).unwrap();
392 assert2::assert!(bytes.len() == 3 * ENTRY_BYTES);
393
394 let reopened = TxnIndex::open(path).unwrap();
395 assert2::assert!(reopened.entries() == entries);
396 }
397
398 #[test]
400 fn an_unsupported_version_is_refused_on_open() {
401 let dir = TempDir::new().unwrap();
402 let path = dir.path().join("00.txnindex");
403 let mut bytes = Vec::new();
404 bytes.extend_from_slice(&1_i16.to_be_bytes()); bytes.extend_from_slice(&1000_i64.to_be_bytes());
406 bytes.extend_from_slice(&0_i64.to_be_bytes());
407 bytes.extend_from_slice(&3_i64.to_be_bytes());
408 bytes.extend_from_slice(&4_i64.to_be_bytes());
409 fs::write(&path, bytes).unwrap();
410 assert2::assert!(let LogError::Corrupt(_) = TxnIndex::open(path).unwrap_err());
411 }
412
413 #[test]
420 fn an_aborted_txn_starting_at_the_range_end_is_outside_it() {
421 let IndexFixture {
422 dir: _dir,
423 path: _path,
424 index,
425 } = ranged_index().name("00000000000000000000.txnindex").call();
426
427 let found =
428 |start: i64, end: i64| index.aborted_in_range(Offset(start), Offset(end)).count();
429 assert2::check!(found(0, 10) == 0, "a fetch ending where the txn begins");
430 assert2::check!(found(0, 11) == 1, "a fetch reaching one past its start");
431 assert2::check!(found(20, 30) == 1, "a fetch starting on its last offset");
432 assert2::check!(found(21, 30) == 0, "a fetch starting past its last offset");
433 }
434
435 #[test]
436 fn empty_file_yields_empty_entries() {
437 let dir = TempDir::new().unwrap();
438 let path = dir.path().join("00.txnindex");
439 let idx = TxnIndex::open(path).unwrap();
440 assert2::assert!(idx.entries() == &[]);
441 }
442
443 #[test]
444 fn malformed_intervals_fail_closed_at_append_and_open() {
445 let dir = TempDir::new().unwrap();
446 let path = dir.path().join("00.txnindex");
447 let mut index = TxnIndex::open(path).unwrap();
448 for MalformedEntry { entry, .. } in malformed_entries() {
449 assert2::assert!(let LogError::InvalidArgument(_) = index.append(entry).unwrap_err());
450 }
451 assert2::assert!(index.entries().is_empty());
452
453 let partial = dir.path().join("partial.txnindex");
454 fs::write(&partial, [0_u8]).unwrap();
455 assert2::assert!(let LogError::Corrupt(_) = TxnIndex::open(partial).unwrap_err());
456
457 for MalformedEntry { name, entry } in malformed_entries() {
458 let path = dir.path().join(format!("{name}.txnindex"));
459 write_entry(&path, entry);
460 assert2::assert!(let LogError::Corrupt(_) = TxnIndex::open(path).unwrap_err());
461 }
462 }
463
464 #[test]
465 fn exact_aborted_interval_retry_is_idempotent() {
466 let entry = retry_entry();
467 let IndexFixture {
468 dir: _dir,
469 path,
470 mut index,
471 } = populated_index("00.txnindex", &[entry]);
472 index.append(entry).unwrap();
473 assert2::assert!(index.entries() == [entry]);
474
475 let raw = AbortedTxnRaw::new(entry);
476 let mut bytes = raw.as_bytes().to_vec();
477 bytes.extend_from_slice(raw.as_bytes());
478 fs::write(&path, bytes).unwrap();
479 assert2::assert!(TxnIndex::open(path).unwrap().entries() == [entry]);
480 }
481
482 #[test]
486 fn reopen_deduplicates_non_adjacent_retries_by_all_fields_in_first_seen_order() {
487 let dir = TempDir::new().unwrap();
488 let path = dir.path().join("00.txnindex");
489 let first = retry_entry();
490 let changed_producer = AbortedTxn {
491 producer_id: ProducerId(first.producer_id.0 + 1),
492 ..first
493 };
494 let changed_start = AbortedTxn {
495 start_offset: Offset(first.start_offset.0 + 1),
496 ..first
497 };
498 let changed_last = AbortedTxn {
499 last_offset: Offset(first.last_offset.0 + 1),
500 ..first
501 };
502 let changed_lso = AbortedTxn {
503 last_stable_offset: Offset(first.last_stable_offset.0 + 1),
504 ..first
505 };
506 let written = [
507 first,
508 changed_producer,
509 changed_start,
510 changed_last,
511 changed_lso,
512 first,
513 ];
514 let bytes = written
515 .iter()
516 .flat_map(|&entry| AbortedTxnRaw::new(entry).as_bytes().to_vec())
517 .collect::<Vec<_>>();
518 fs::write(&path, &bytes).unwrap();
519
520 let mut reopened = TxnIndex::open(path.clone()).unwrap();
521 assert2::assert!(reopened.entries() == &written[..5]);
522
523 reopened.append(first).unwrap();
525 assert2::check!(reopened.entries() == &written[..5]);
526 assert2::check!(fs::read(path).unwrap() == bytes);
527 }
528
529 #[test]
530 fn mutation_io_failures_leave_the_in_memory_index_unchanged() {
531 let original = retry_entry();
532 let IndexFixture {
533 dir: _dir,
534 path,
535 mut index,
536 } = populated_index("00.txnindex", &[original]);
537 fs::remove_file(&path).unwrap();
538 fs::create_dir(&path).unwrap();
539
540 assert2::assert!(
541 let LogError::Io(_) = index
542 .append(aborted_txn().bounds(Offset(10)..=Offset(12)).stable(13).call())
543 .unwrap_err()
544 );
545 assert2::assert!(index.entries() == [original]);
546 assert2::assert!(let LogError::Io(_) = index.truncate_from(Offset(0)).unwrap_err());
547 assert2::assert!(index.entries() == [original]);
548 }
549
550 #[test]
551 fn empty_or_inverted_fetch_range_matches_no_aborted_interval() {
552 let IndexFixture {
553 dir: _dir,
554 path: _path,
555 index,
556 } = ranged_index().call();
557 assert2::assert!(index.aborted_in_range(Offset(10), Offset(10)).count() == 0);
558 assert2::assert!(index.aborted_in_range(Offset(20), Offset(10)).count() == 0);
559 }
560
561 #[test]
562 fn append_round_trips_through_disk() {
563 let dir = TempDir::new().unwrap();
564 let path = dir.path().join("00.txnindex");
565 let mut idx = TxnIndex::open(path.clone()).unwrap();
566 idx.append(retry_entry()).unwrap();
567 idx.append(
568 aborted_txn()
569 .bounds(Offset(10)..=Offset(12))
570 .stable(13)
571 .call(),
572 )
573 .unwrap();
574
575 let idx2 = TxnIndex::open(path).unwrap();
576 assert2::assert!(
577 idx2.entries()
578 == &[
579 AbortedTxn {
580 start_offset: Offset(5),
581 last_offset: Offset(7),
582 producer_id: ProducerId(1000),
583 last_stable_offset: Offset(8)
584 },
585 AbortedTxn {
586 start_offset: Offset(10),
587 last_offset: Offset(12),
588 producer_id: ProducerId(1000),
589 last_stable_offset: Offset(13)
590 },
591 ]
592 );
593 }
594
595 #[test]
596 fn truncate_from_removes_overlapping_tail_entries_and_is_retryable() {
597 let dir = TempDir::new().unwrap();
598 let path = dir.path().join("00.txnindex");
599 let mut index = TxnIndex::open(path.clone()).unwrap();
600 for (start, last) in [(0, 4), (5, 9)] {
601 index
602 .append(AbortedTxn {
603 start_offset: Offset(start),
604 last_offset: Offset(last),
605 producer_id: ProducerId(1),
606 last_stable_offset: Offset(last + 1),
607 })
608 .unwrap();
609 }
610
611 for _ in 0..2 {
612 index.truncate_from(Offset(5)).unwrap();
613 assert2::assert!(index.entries().len() == 1);
614 assert2::assert!(index.entries()[0].last_offset == Offset(4));
615 }
616 let reopened = TxnIndex::open(path).unwrap();
617 assert2::assert!(reopened.entries() == index.entries());
618
619 index.truncate_from(Offset(4)).unwrap();
620 assert2::assert!(index.entries().is_empty());
621 }
622
623 #[test]
624 fn truncate_removes_membership_so_the_same_entry_can_be_reappended() {
625 let entry = retry_entry();
626 let IndexFixture {
627 dir: _dir,
628 path,
629 mut index,
630 } = populated_index("00.txnindex", &[entry]);
631
632 index.truncate_from(entry.last_offset).unwrap();
633 assert2::assert!(index.entries().is_empty());
634 index.append(entry).unwrap();
635 assert2::check!(index.entries() == [entry]);
636 assert2::check!(TxnIndex::open(path).unwrap().entries() == [entry]);
637 }
638
639 #[test]
640 fn aborted_in_range_overlaps() {
641 let dir = TempDir::new().unwrap();
642 let path = dir.path().join("00.txnindex");
643 let mut idx = TxnIndex::open(path).unwrap();
644 idx.append(
645 aborted_txn()
646 .producer(1)
647 .bounds(Offset(0)..=Offset(4))
648 .stable(5)
649 .call(),
650 )
651 .unwrap();
652 idx.append(
653 aborted_txn()
654 .producer(2)
655 .bounds(Offset(10)..=Offset(14))
656 .stable(15)
657 .call(),
658 )
659 .unwrap();
660
661 let in_3_to_12 = idx
662 .aborted_in_range(Offset(3), Offset(12))
663 .copied()
664 .collect::<Vec<_>>();
665 let in_5_to_9 = idx
666 .aborted_in_range(Offset(5), Offset(9))
667 .copied()
668 .collect::<Vec<_>>();
669 assert2::assert!(
670 in_3_to_12
671 == vec![
672 aborted_txn()
673 .producer(1)
674 .bounds(Offset(0)..=Offset(4))
675 .stable(5)
676 .call(),
677 aborted_txn()
678 .producer(2)
679 .bounds(Offset(10)..=Offset(14))
680 .stable(15)
681 .call(),
682 ]
683 );
684 assert2::assert!(in_5_to_9 == Vec::new());
685 }
686
687 fn write_nothing(_: &mut TxnIndex) {}
689
690 fn append_one(index: &mut TxnIndex) { index.append(retry_entry()).unwrap(); }
692
693 fn append_then_empty(index: &mut TxnIndex) {
695 append_one(index);
696 index.truncate_from(Offset(0)).unwrap();
697 }
698
699 #[yare::parameterized(
702 untouched = { write_nothing, false },
703 appended = { append_one, true },
704 appended_then_emptied = { append_then_empty, true },
705 )]
706 fn an_index_knows_whether_its_file_exists(write: fn(&mut TxnIndex), exists: bool) {
707 let dir = TempDir::new().unwrap();
708 let path = dir.path().join("00.txnindex");
709 let mut index = TxnIndex::open(path.clone()).unwrap();
710
711 write(&mut index);
712
713 assert2::check!(index.on_disk() == exists);
714 assert2::check!(path.exists() == exists);
715 assert2::check!(TxnIndex::open(path).unwrap().on_disk() == exists);
716 }
717
718 #[yare::parameterized(
722 nothing_completes = { 0, 30, &[0, 1, 2], false },
723 the_first_entry_completes = { 0, 5, &[0], true },
724 a_later_entry_completes = { 0, 12, &[0, 1], true },
725 the_completing_entry_lies_before_the_range = { 10, 11, &[], true },
726 an_empty_range = { 10, 10, &[], true },
727 )]
728 fn collect_aborted_stops_at_the_first_complete_entry(
729 start: i64,
730 end: i64,
731 taken: &[usize],
732 complete: bool,
733 ) {
734 let entries = [
735 aborted_txn()
736 .producer(1)
737 .bounds(Offset(0)..=Offset(4))
738 .stable(5)
739 .call(),
740 aborted_txn()
741 .producer(2)
742 .bounds(Offset(6)..=Offset(9))
743 .stable(12)
744 .call(),
745 aborted_txn()
746 .producer(3)
747 .bounds(Offset(3)..=Offset(20))
748 .stable(21)
749 .call(),
750 ];
751 let IndexFixture {
752 dir: _dir,
753 path: _path,
754 index,
755 } = populated_index("00.txnindex", &entries);
756 let mut out = vec![entries[2]];
757
758 let actual = index.collect_aborted(Offset(start), Offset(end), &mut out);
759
760 let expected: Vec<AbortedTxn> = iter::once(entries[2])
761 .chain(taken.iter().map(|&at| entries[at]))
762 .collect();
763 assert2::check!((actual, out) == (complete, expected));
764 }
765}